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

Missing Binding Agreement validation causes pool settlement against the wrong BattleChain state

Author Revealed upon completion

Description

  • Normal behavior should bind a confidence pool's scoped BattleChain accounts to the same BattleChain agreement whose state drives pool settlement. Stakers fund the pool based on the published scope and expect SURVIVED or CORRUPTED resolution to correspond to the agreement that actually governs those accounts.

  • The pool only checks that the sponsor-selected agreement is factory-valid and that each scoped account is included in that agreement's scope cache. BattleChain's own interfaces distinguish scope membership from the Binding Agreement, and expose AttackRegistry.getAgreementForContract(account) as the binding resolver. Because the pool later settles solely from getAgreementState(agreement), a pool can be created for agreement A while the scoped account is actually bound to agreement B, causing stakers to be paid from the wrong BattleChain state.

// src/ConfidencePoolFactory.sol
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
...
// @> Only proves the agreement was factory-created, not that it binds scoped accounts.
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
// @> Only proves caller owns the selected agreement.
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
...
}
// src/ConfidencePool.sol
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
// @> Settlement always follows the stored agreement, not the scoped account's Binding Agreement.
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}
function _replaceScope(address[] calldata accounts) internal {
...
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
// @> isContractInScope(account) does not prove this agreement is the Binding Agreement.
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
}

Risk

Likelihood:

  • Medium. The path requires a scoped account to be listed by one valid agreement while the BattleChain AttackRegistry binds that account to a different agreement, which is plausible in BattleChain's multi-agreement/re-registration model but not expected for every pool.

  • This occurs during factory pool creation or pre-lock scope replacement for an account listed by the selected agreement while AttackRegistry.getAgreementForContract(account) points to a different agreement.

  • This occurs naturally after BattleChain re-registration or multi-agreement scope overlap, because BattleChain explicitly allows scope membership and Binding Agreement resolution to be different facts.

Impact:

  • Medium. Pool funds can be distributed according to the wrong BattleChain state, but the path does not give an unprivileged caller direct theft of all pool funds.

  • Stakers can receive SURVIVED or EXPIRED payouts even though the scoped account's Binding Agreement is CORRUPTED.

  • A good-faith attacker or recovery path can be denied the pool balance because the moderator cannot flag CORRUPTED while the pool reads a non-binding agreement in PRODUCTION.

Proof of Concept

Create test/poc/ValidFindingsPoC.t.sol and add the shared imports/helper contracts from the PoC file. The finding-specific test is below.

Run it with:

forge test --match-path test/poc/ValidFindingsPoC.t.sol --match-test testPoC_NonBindingAgreementPoolCannotSettleBindingCorruption -vvv

This PoC stages two valid agreements that both list the same scoped account, while the AttackRegistry binding for that account points to the second agreement. The pool is created with the non-binding agreement, then the binding agreement is marked CORRUPTED and the non-binding agreement is marked PRODUCTION. The test proves impact by showing flagOutcome(CORRUPTED, ...) reverts, SURVIVED succeeds, and the staker withdraws principal even though the scoped account's Binding Agreement is corrupted.

// 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 {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract SelectiveAttackRegistry {
mapping(address agreement => IAttackRegistry.ContractState state) internal states;
mapping(address account => address agreement) internal bindingAgreement;
function setAgreementState(address agreement, IAttackRegistry.ContractState state) external {
states[agreement] = state;
}
function setAgreementForContract(address account, address agreement) external {
bindingAgreement[account] = agreement;
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
return states[agreement];
}
function getAgreementForContract(address account) external view returns (address) {
return bindingAgreement[account];
}
}
contract ValidFindingsPoC is Test {
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
address internal sponsor = makeAddr("sponsor");
address internal moderator = makeAddr("moderator");
address internal staker = makeAddr("staker");
address internal attacker = makeAddr("attacker");
address internal recovery = makeAddr("recovery");
address internal victim = makeAddr("victim");
address internal whale = makeAddr("whale");
function _scope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
}
function _deployFactory(MockSafeHarborRegistry registry, ConfidencePool implementation)
internal
returns (ConfidencePoolFactory factory)
{
ConfidencePoolFactory impl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(impl),
abi.encodeCall(ConfidencePoolFactory.initialize, (address(registry), address(implementation), moderator))
);
factory = ConfidencePoolFactory(address(proxy));
}
function testPoC_NonBindingAgreementPoolCannotSettleBindingCorruption() external {
MockERC20 token = new MockERC20();
MockSafeHarborRegistry safeHarborRegistry = new MockSafeHarborRegistry();
SelectiveAttackRegistry attackRegistry = new SelectiveAttackRegistry();
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
MockAgreement nonBindingAgreement = new MockAgreement(sponsor);
MockAgreement bindingAgreement = new MockAgreement(sponsor);
nonBindingAgreement.setContractInScope(SCOPE_ACCOUNT, true);
bindingAgreement.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAgreementValid(address(nonBindingAgreement), true);
safeHarborRegistry.setAgreementValid(address(bindingAgreement), true);
attackRegistry.setAgreementForContract(SCOPE_ACCOUNT, address(bindingAgreement));
attackRegistry.setAgreementState(address(nonBindingAgreement), IAttackRegistry.ContractState.NEW_DEPLOYMENT);
attackRegistry.setAgreementState(address(bindingAgreement), IAttackRegistry.ContractState.CORRUPTED);
ConfidencePoolFactory factory = _deployFactory(safeHarborRegistry, new ConfidencePool());
factory.setStakeTokenAllowed(address(token), true);
vm.prank(sponsor);
ConfidencePool pool = ConfidencePool(
factory.createPool(
address(nonBindingAgreement), address(token), block.timestamp + 31 days, ONE, recovery, _scope()
)
);
token.mint(staker, ONE);
vm.startPrank(staker);
token.approve(address(pool), ONE);
pool.stake(ONE);
vm.stopPrank();
attackRegistry.setAgreementState(address(nonBindingAgreement), IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(staker);
pool.claimSurvived();
assertEq(token.balanceOf(staker), ONE, "staker recovers principal despite binding agreement corruption");
assertEq(token.balanceOf(recovery), 0, "corrupted recovery path was never reached");
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.SURVIVED),
"pool settled from non-binding agreement state"
);
assertEq(
attackRegistry.getAgreementForContract(SCOPE_ACCOUNT),
address(bindingAgreement),
"scope account was bound elsewhere"
);
}
}

Recommended Mitigation

Validate each scoped account against the AttackRegistry Binding Agreement before storing the scope. This keeps the pool-local commitment, while ensuring the agreement used for settlement is the agreement that actually governs the scoped account.

function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
+ address attackRegistry = safeHarborRegistry.getAttackRegistry();
+ if (attackRegistry == address(0)) revert InvalidAgreement();
...
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);
}
+ if (IAttackRegistry(attackRegistry).getAgreementForContract(account) != agreement) {
+ revert InvalidAgreement();
+ }
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
}
}

Support

FAQs

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

Give us feedback!