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

Pools do not track post-creation BattleChain re-linking, so a contract re-linked to a new Binding Agreement can still resolve against stale agreement state

Author Revealed upon completion

Root + Impact

Description

Normal behavior: a confidence pool should settle according to the live BattleChain state of the contracts in its committed scope. When a scoped contract's current Binding Agreement is corrupted, the pool should not treat that contract as having safely survived merely because an older agreement remains healthy.

Specific issue: the pool snapshots agreement during initialization and later calls the attack registry with that address only. BattleChain exposes getAgreementForContract(contractAddress) as the current binding source, but the pool does not consult it after creation. As a result, a post-creation re-link from agreement A to agreement B leaves the pool reading stale state from A.

Root cause in the codebase:

function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}

The stored agreement is treated as permanently authoritative, while the scoped contract's live getAgreementForContract(scopeAccount) binding is ignored.

Risk

Likelihood:

  • Reason 1: This occurs when a pool is created while the scoped contract is correctly bound to agreement A, and BattleChain later legally re-links that same scoped contract to agreement B.

  • Reason 2: This occurs when agreement B enters an attack or terminal corrupted state while agreement A remains in a state that permits the pool to continue staking, withdrawing, expiring, or being flagged as survived.

Impact:

  • Impact 1: A moderator can incorrectly flag SURVIVED using stale agreement A even though the scoped contract's current Binding Agreement B is CORRUPTED.

  • Impact 2: Stakers can claim principal through claimSurvived(), causing funds that should be reserved for corrupted/recovery handling to leave the pool.

Proof of Concept

// 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 {PoolStates} from "src/libraries/PoolStates.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockBindingAwareAttackRegistry} from "test/mocks/MockBindingAwareAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract BindingAgreementRelinkStaleStateTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockSafeHarborRegistry internal registry;
MockBindingAwareAttackRegistry internal attackRegistry;
MockAgreement internal agreementA;
MockAgreement internal agreementB;
ConfidencePoolFactory internal factory;
address internal moderator = makeAddr("moderator");
address internal agreementOwner = makeAddr("agreementOwner");
address internal recovery = makeAddr("recovery");
address internal staker = makeAddr("staker");
function setUp() public {
token = new MockERC20();
registry = new MockSafeHarborRegistry();
attackRegistry = new MockBindingAwareAttackRegistry();
agreementA = new MockAgreement(agreementOwner);
agreementB = new MockAgreement(agreementOwner);
agreementA.setContractInScope(SCOPE_ACCOUNT, true);
agreementB.setContractInScope(SCOPE_ACCOUNT, true);
registry.setAttackRegistry(address(attackRegistry));
registry.setAgreementValid(address(agreementA), true);
registry.setAgreementValid(address(agreementB), true);
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(ConfidencePoolFactory.initialize, (address(registry), address(implementation), moderator))
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
}
function testRelinkedBindingAgreementCorruptionStillResolvesAgainstStaleCreationAgreement() external {
address[] memory accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
// Creation is correct: the scoped contract is directly bound to agreement A.
attackRegistry.setBindingAgreement(SCOPE_ACCOUNT, address(agreementA));
attackRegistry.setAgreementState(address(agreementA), IAttackRegistry.ContractState.NEW_DEPLOYMENT);
vm.prank(agreementOwner);
address poolAddr =
factory.createPool(address(agreementA), address(token), block.timestamp + 31 days, ONE, recovery, accounts);
ConfidencePool pool = ConfidencePool(poolAddr);
token.mint(staker, 100 * ONE);
vm.startPrank(staker);
token.approve(poolAddr, 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
// Upstream BattleChain later re-links the same contract to agreement B. B is corrupted,
// while the creation-time agreement A has reached PRODUCTION.
attackRegistry.setBindingAgreement(SCOPE_ACCOUNT, address(agreementB));
attackRegistry.setAgreementState(address(agreementA), IAttackRegistry.ContractState.PRODUCTION);
attackRegistry.setAgreementState(address(agreementB), IAttackRegistry.ContractState.CORRUPTED);
assertEq(attackRegistry.getAgreementForContract(SCOPE_ACCOUNT), address(agreementB), "contract re-linked to B");
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementB))),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"current binding agreement is corrupted"
);
// Vulnerable behavior: the pool reads getAgreementState(agreementA), accepts SURVIVED,
// and returns principal even though the scoped contract now resolves to corrupted B.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 beforeClaim = token.balanceOf(staker);
vm.prank(staker);
pool.claimSurvived();
assertEq(
uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED), "wrong outcome accepted from stale A"
);
assertEq(token.balanceOf(staker), beforeClaim + 100 * ONE, "staker recovered principal");
assertEq(token.balanceOf(poolAddr), 0, "pool drained through survived path");
}
}

Recommended Mitigation

Do not treat the creation-time agreement as the permanent state source. When observing registry state, resolve the current direct Binding Agreement for each scoped contract first:

address currentAgreement = attackRegistry.getAgreementForContract(scopeAccount);
if (currentAgreement != address(0)) {
return attackRegistry.getAgreementState(currentAgreement);
}

Only fall back to the stored agreement when there is no direct binding and that fallback is explicitly intended. For multi-account scopes, use a conservative aggregation rule, such as treating any directly bound CORRUPTED account as corrupted and any active-risk account as active risk.

Support

FAQs

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

Give us feedback!