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

Locked accounts can be rebound to a corrupted agreement while the pool settles against the stale original agreement

Author Revealed upon completion

Description

Root + Impact

ConfidencePool stores a locked list of covered accounts, but every outcome decision queries only
the Agreement address supplied at initialization. Safe Harbor permits an account to be removed from
that Agreement after its commitment window and registered under a replacement Agreement. When the
replacement becomes CORRUPTED while the original remains UNDER_ATTACK or reaches PRODUCTION,
the pool cannot flag CORRUPTED and instead pays EXPIRED or SURVIVED. The whitehat receives
nothing even though the corrupted account remains in the pool's locked scope.

Description

The pool-local commitment and settlement source can drift apart:

address public agreement;
address[] internal _scopeAccounts;
mapping(address account => bool inScope) public isAccountInScope;
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
@> return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}

Moderator authorization and mechanical settlement both consume this stale value:

function flagOutcome(...) external onlyModerator {
IAttackRegistry.ContractState state = _observePoolState();
// ...
@> if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
}
function claimExpired() external nonReentrant {
IAttackRegistry.ContractState state = _observePoolState();
// ...
@> if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
} else {
@> outcome = PoolStates.Outcome.EXPIRED;
}
}

This violates the documented fixed-scope invariant: docs/DESIGN.md states that the pool-local
scope remains the binding commitment even when the sponsor narrows the underlying Agreement.

The pinned Safe Harbor dependency makes the path concrete. After the commitment window,
Agreement.removeAccounts() removes a BattleChain account and calls
AttackRegistry.unregisterContractForExistingAgreement(). Unregistration deletes the current
binding without rejecting an original Agreement that is still UNDER_ATTACK. The account can then
be registered under a replacement Agreement. The authoritative lookup is
getAgreementForContract(account); both isAgreementValid() and isContractInScope() explicitly
warn that they do not establish the current Binding Agreement.

The dependency behavior is not reported as vulnerable. The in-scope defect is that the pool
promises fixed account coverage while resolving solely from an obsolete Agreement identity.

Risk

Likelihood

  • Occurs when the original Agreement's commitment window expires while the pool remains funded.

  • Occurs when a locked account is removed from the original Agreement and bound to a replacement Agreement.

  • Occurs when the replacement Agreement completes its registry approval path and becomes CORRUPTED.

  • Occurs when the original Agreement is not itself CORRUPTED, so the pool rejects the correct moderator outcome.

  • Occurs when the supported multi-step rebinding lifecycle completes, whose required sequencing makes likelihood Low.

Impact

  • A good-faith whitehat receives zero despite corruption of an account still listed in locked pool scope.

  • Stakers receive principal and bonus under the wrong EXPIRED or SURVIVED outcome.

  • The misallocated amount is the complete balance of the affected pool.

  • The bug breaks the core bounty/slashing invariant rather than only exposing stale metadata.

Proof of Concept

Primary target-repository PoC is embedded below. If the reviewer wants to run it, save the primary code as test/poc/CodexGpt5_20260713_ActiveBindingRemovalPaysExpired.t.sol in the target repository and run:

forge test --match-contract CodexGpt5_20260713_ActiveBindingRemovalPaysExpiredTest -vvv

Observed at commit 58e8ba4ce3f3277866e4926f3140e597f9554a1e:

[PASS] testLockedAccountRemovedFromActiveAgreementCanStillSettleExpired()
1 passed; 0 failed; 0 skipped

This target-repository test models the exact registry binding operations and proves the in-scope
pool reads the stale original Agreement.

The supporting integration PoC embedded below additionally deploys the pinned real Safe Harbor Agreement, AgreementFactory, AttackRegistry, and registry contracts, then deploys the actual compiled ConfidencePool bytecode. It executes real account removal, unregistration,
replacement registration and approval, markCorrupted(), and pool expiry. Observed result:

[PASS] testRealRemoveAndRebindMakesCurrentCorruptionSettleExpired()
1 passed; 0 failed; 0 skipped

Full Primary PoC Code

File: CodexGpt5_20260713_ActiveBindingRemovalPaysExpired.t.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.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 CodexGpt5_20260713_ActiveBindingRegistryModel {
mapping(address agreement => IAttackRegistry.ContractState state) internal _state;
mapping(address account => address agreement) internal _binding;
function setAgreementState(address agreement, IAttackRegistry.ContractState next) external {
_state[agreement] = next;
}
function bind(address account, address agreement) external {
_binding[account] = agreement;
}
function unbind(address account, address agreement) external {
if (_binding[account] == agreement) delete _binding[account];
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
return _state[agreement];
}
function getAgreementForContract(address account) external view returns (address) {
return _binding[account];
}
}
contract CodexGpt5_20260713_ActiveAgreementModel {
address public owner;
CodexGpt5_20260713_ActiveBindingRegistryModel internal registry;
mapping(address account => bool inScope) internal _inScope;
constructor(address owner_, CodexGpt5_20260713_ActiveBindingRegistryModel registry_) {
owner = owner_;
registry = registry_;
}
function addAndBind(address account) external {
_inScope[account] = true;
registry.bind(account, address(this));
}
function removeAndUnbind(address account) external {
_inScope[account] = false;
registry.unbind(account, address(this));
}
function isContractInScope(address account) external view returns (bool) {
return _inScope[account];
}
}
contract CodexGpt5_20260713_ActiveBindingRemovalPaysExpiredTest is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant START = 1_750_000_000;
address internal sponsor = makeAddr("active-binding-sponsor");
address internal moderator = makeAddr("active-binding-pool-moderator");
address internal recovery = makeAddr("active-binding-recovery");
address internal whitehat = makeAddr("active-binding-whitehat");
address internal passiveStaker = makeAddr("active-binding-passive-staker");
address internal bonusFunder = makeAddr("active-binding-bonus-funder");
address internal lockedAccount = address(0xA71CE_20260713);
address internal companion = address(0xB0B_20260713);
MockERC20 internal token;
MockSafeHarborRegistry internal safeHarbor;
CodexGpt5_20260713_ActiveBindingRegistryModel internal registry;
CodexGpt5_20260713_ActiveAgreementModel internal originalAgreement;
CodexGpt5_20260713_ActiveAgreementModel internal replacementAgreement;
ConfidencePool internal pool;
function setUp() external {
vm.warp(START);
token = new MockERC20();
safeHarbor = new MockSafeHarborRegistry();
registry = new CodexGpt5_20260713_ActiveBindingRegistryModel();
safeHarbor.setAttackRegistry(address(registry));
originalAgreement = new CodexGpt5_20260713_ActiveAgreementModel(sponsor, registry);
replacementAgreement = new CodexGpt5_20260713_ActiveAgreementModel(sponsor, registry);
originalAgreement.addAndBind(lockedAccount);
originalAgreement.addAndBind(companion);
safeHarbor.setAgreementValid(address(originalAgreement), true);
registry.setAgreementState(address(originalAgreement), IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory scope = new address[](1);
scope[0] = lockedAccount;
pool.initialize(
address(originalAgreement),
address(token),
address(safeHarbor),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
sponsor,
scope
);
}
function testLockedAccountRemovedFromActiveAgreementCanStillSettleExpired() external {
_stake(sponsor, 100 * ONE);
_stake(passiveStaker, 50 * ONE);
_contributeBonus(50 * ONE);
registry.setAgreementState(address(originalAgreement), IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertTrue(pool.scopeLocked(), "pool-local scope is locked while account is still bound");
assertEq(registry.getAgreementForContract(lockedAccount), address(originalAgreement));
// Model the upstream Agreement/AttackRegistry sync after the commitment window: the account
// leaves the active original agreement and is rebound to a replacement agreement that corrupts.
originalAgreement.removeAndUnbind(lockedAccount);
replacementAgreement.addAndBind(lockedAccount);
registry.setAgreementState(address(replacementAgreement), IAttackRegistry.ContractState.CORRUPTED);
assertTrue(pool.isAccountInScope(lockedAccount), "pool still claims to cover the locked account");
assertEq(registry.getAgreementForContract(lockedAccount), address(replacementAgreement));
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.warp(pool.expiry());
uint256 sponsorBefore = token.balanceOf(sponsor);
uint256 stakerBefore = token.balanceOf(passiveStaker);
vm.prank(sponsor);
pool.claimExpired();
vm.prank(passiveStaker);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "stale original state wins");
assertEq(token.balanceOf(sponsor) - sponsorBefore, 133_333333333333333333);
assertEq(token.balanceOf(passiveStaker) - stakerBefore, 66_666666666666666666);
assertEq(token.balanceOf(whitehat), 0, "current-binding corruption never reaches bounty path");
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(uint256 amount) internal {
token.mint(bonusFunder, amount);
vm.startPrank(bonusFunder);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
}

Full Supporting Real Safe Harbor Integration PoC Code

File: CodexGpt5_20260714_RealBindingReassignmentBypassesPool.t.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.34;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {
Account as AgreementAccount,
AgreementDetails,
BountyTerms,
Chain as AgreementChain,
ChildContractScope,
Contact,
IdentityRequirements
} from "src/types/AgreementTypes.sol";
import {Agreement} from "src/Agreement.sol";
import {AgreementFactory} from "src/AgreementFactory.sol";
import {AttackRegistry} from "src/AttackRegistry.sol";
import {BattleChainSafeHarborRegistry} from "src/BattleChainSafeHarborRegistry.sol";
import {IAttackRegistry} from "src/interface/IAttackRegistry.sol";
import {MockERC20} from "test/mock/MockERC20.sol";
enum ExternalPoolOutcome {
UNRESOLVED,
SURVIVED,
CORRUPTED,
EXPIRED
}
interface IExternalConfidencePool {
function initialize(
address agreement,
address stakeToken,
address safeHarborRegistry,
address outcomeModerator,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address owner,
address[] calldata accounts
) external;
function stake(uint256 amount) external;
function contributeBonus(uint256 amount) external;
function flagOutcome(ExternalPoolOutcome outcome, bool goodFaith, address attacker) external;
function claimExpired() external;
function expiry() external view returns (uint32);
function outcome() external view returns (ExternalPoolOutcome);
function scopeLocked() external view returns (bool);
function isAccountInScope(address account) external view returns (bool);
}
contract CodexGpt5_20260714_RealBindingReassignmentBypassesPoolTest is Test {
string internal constant BATTLE_CHAIN = "eip155:627";
uint256 internal constant ONE = 1e18;
address internal daoOwner = makeAddr("binding-real-dao-owner");
address internal registryModerator = makeAddr("binding-real-registry-moderator");
address internal treasury = makeAddr("binding-real-treasury");
address internal sponsor = makeAddr("binding-real-sponsor");
address internal poolModerator = makeAddr("binding-real-pool-moderator");
address internal recovery = makeAddr("binding-real-recovery");
address internal staker = makeAddr("binding-real-staker");
address internal bonusFunder = makeAddr("binding-real-bonus-funder");
address internal whitehat = makeAddr("binding-real-whitehat");
address internal lockedAccount = makeAddr("binding-real-locked-account");
address internal originalAnchor = makeAddr("binding-real-original-anchor");
address internal replacementAnchor = makeAddr("binding-real-replacement-anchor");
BattleChainSafeHarborRegistry internal safeHarborRegistry;
AgreementFactory internal agreementFactory;
AttackRegistry internal attackRegistry;
MockERC20 internal token;
Agreement internal originalAgreement;
Agreement internal replacementAgreement;
IExternalConfidencePool internal pool;
function setUp() external {
vm.warp(1_900_000_000);
_deployRealRegistryStack();
address[] memory originalAccounts = new address[](2);
originalAccounts[0] = lockedAccount;
originalAccounts[1] = originalAnchor;
originalAgreement = _createAgreement(originalAccounts, bytes32("original"));
address[] memory replacementAccounts = new address[](1);
replacementAccounts[0] = replacementAnchor;
replacementAgreement = _createAgreement(replacementAccounts, bytes32("replacement"));
vm.prank(sponsor);
attackRegistry.requestUnderAttackForUnverifiedContracts(address(originalAgreement));
vm.prank(registryModerator);
attackRegistry.approveAttack(address(originalAgreement));
pool = _deployPoolClone(address(originalAgreement), lockedAccount);
token.mint(staker, 100 * ONE);
vm.startPrank(staker);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
token.mint(bonusFunder, 50 * ONE);
vm.startPrank(bonusFunder);
token.approve(address(pool), 50 * ONE);
pool.contributeBonus(50 * ONE);
vm.stopPrank();
assertTrue(pool.scopeLocked());
assertTrue(pool.isAccountInScope(lockedAccount));
assertEq(attackRegistry.getAgreementForContract(lockedAccount), address(originalAgreement));
}
function testRealRemoveAndRebindMakesCurrentCorruptionSettleExpired() external {
vm.warp(block.timestamp + 31 days);
string[] memory toRemove = new string[](1);
toRemove[0] = _addressToString(lockedAccount);
vm.prank(sponsor);
originalAgreement.removeAccounts(BATTLE_CHAIN, toRemove);
assertEq(attackRegistry.getAgreementForContract(lockedAccount), address(0));
vm.prank(sponsor);
replacementAgreement.extendCommitmentWindow(block.timestamp + 30 days);
AgreementAccount[] memory toAdd = new AgreementAccount[](1);
toAdd[0] =
AgreementAccount({accountAddress: _addressToString(lockedAccount), childContractScope: ChildContractScope.None});
vm.prank(sponsor);
replacementAgreement.addAccounts(BATTLE_CHAIN, toAdd);
vm.prank(sponsor);
attackRegistry.requestUnderAttackForUnverifiedContracts(address(replacementAgreement));
vm.prank(registryModerator);
attackRegistry.approveAttack(address(replacementAgreement));
vm.prank(sponsor);
attackRegistry.markCorrupted(address(replacementAgreement));
assertEq(attackRegistry.getAgreementForContract(lockedAccount), address(replacementAgreement));
assertEq(
uint256(attackRegistry.getAgreementState(address(replacementAgreement))),
uint256(IAttackRegistry.ContractState.CORRUPTED)
);
assertEq(
uint256(attackRegistry.getAgreementState(address(originalAgreement))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
vm.prank(poolModerator);
vm.expectRevert(bytes4(keccak256("InvalidOutcome()")));
pool.flagOutcome(ExternalPoolOutcome.CORRUPTED, true, whitehat);
vm.warp(pool.expiry());
uint256 before = token.balanceOf(staker);
vm.prank(staker);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(ExternalPoolOutcome.EXPIRED));
assertEq(token.balanceOf(staker) - before, 150 * ONE);
assertEq(token.balanceOf(whitehat), 0);
assertEq(token.balanceOf(recovery), 0);
}
function _deployRealRegistryStack() internal {
BattleChainSafeHarborRegistry safeImpl = new BattleChainSafeHarborRegistry();
safeHarborRegistry =
BattleChainSafeHarborRegistry(address(new ERC1967Proxy(address(safeImpl), bytes(""))));
AgreementFactory agreementFactoryImpl = new AgreementFactory();
agreementFactory = AgreementFactory(address(new ERC1967Proxy(address(agreementFactoryImpl), bytes(""))));
AttackRegistry attackRegistryImpl = new AttackRegistry();
attackRegistry = AttackRegistry(address(new ERC1967Proxy(address(attackRegistryImpl), bytes(""))));
agreementFactory.initialize(daoOwner, address(safeHarborRegistry), BATTLE_CHAIN);
attackRegistry.initialize(
daoOwner,
registryModerator,
address(safeHarborRegistry),
address(agreementFactory),
makeAddr("binding-real-battlechain-deployer"),
treasury
);
string[] memory validChains = new string[](1);
validChains[0] = BATTLE_CHAIN;
safeHarborRegistry.initialize(
daoOwner, validChains, address(agreementFactory), address(attackRegistry)
);
token = new MockERC20();
}
function _createAgreement(address[] memory scopedAccounts, bytes32 salt) internal returns (Agreement result) {
AgreementAccount[] memory accounts = new AgreementAccount[](scopedAccounts.length);
for (uint256 i; i < scopedAccounts.length; ++i) {
accounts[i] = AgreementAccount({
accountAddress: _addressToString(scopedAccounts[i]),
childContractScope: ChildContractScope.None
});
}
AgreementChain[] memory chains = new AgreementChain[](1);
chains[0] = AgreementChain({
assetRecoveryAddress: _addressToString(recovery),
accounts: accounts,
caip2ChainId: BATTLE_CHAIN
});
Contact[] memory contacts = new Contact[](1);
contacts[0] = Contact({name: "Security", contact: "security@example.invalid"});
BountyTerms memory terms = BountyTerms({
bountyPercentage: 10,
bountyCapUsd: 1_000_000,
retainable: true,
identity: IdentityRequirements.Anonymous,
diligenceRequirements: "",
aggregateBountyCapUsd: 0
});
AgreementDetails memory details = AgreementDetails({
protocolName: "Binding Integration",
contactDetails: contacts,
chains: chains,
bountyTerms: terms,
agreementURI: "ipfs://binding-integration"
});
vm.prank(sponsor);
result = Agreement(agreementFactory.create(details, sponsor, salt));
vm.prank(sponsor);
result.extendCommitmentWindow(block.timestamp + 30 days);
}
function _deployPoolClone(address agreement, address scopedAccount)
internal
returns (IExternalConfidencePool deployedPool)
{
bytes memory confidencePoolCreationCode = vm.getCode(vm.envString("CONFIDENCE_POOL_ARTIFACT"));
address implementation;
assembly {
implementation := create(
0, add(confidencePoolCreationCode, 0x20), mload(confidencePoolCreationCode)
)
}
require(implementation != address(0), "pool implementation deployment failed");
deployedPool = IExternalConfidencePool(Clones.clone(implementation));
address[] memory accounts = new address[](1);
accounts[0] = scopedAccount;
deployedPool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
poolModerator,
block.timestamp + 120 days,
ONE,
recovery,
sponsor,
accounts
);
}
function _addressToString(address account) internal pure returns (string memory) {
bytes memory alphabet = "0123456789abcdef";
bytes memory encoded = new bytes(42);
encoded[0] = "0";
encoded[1] = "x";
for (uint256 i; i < 20; ++i) {
encoded[2 + i * 2] = alphabet[uint8(uint160(account) >> (8 * (19 - i)) >> 4) & 0xf];
encoded[3 + i * 2] = alphabet[uint8(uint160(account) >> (8 * (19 - i))) & 0xf];
}
return string(encoded);
}
}

Recommended Mitigation

Resolve each locked account's current Binding Agreement before authorizing or mechanically
selecting an outcome:

diff --git a/src/ConfidencePool.sol b/src/ConfidencePool.sol
@@
+function _hasCorruptedScopedBinding() internal view returns (bool) {
+ IAttackRegistry registry =
+ IAttackRegistry(safeHarborRegistry.getAttackRegistry());
+ for (uint256 i; i < _scopeAccounts.length; ++i) {
+ address current = registry.getAgreementForContract(_scopeAccounts[i]);
+ if (
+ current != address(0)
+ && registry.getAgreementState(current) == IAttackRegistry.ContractState.CORRUPTED
+ ) return true;
+ }
+ return false;
+}
@@ function flagOutcome(...) external onlyModerator {
- if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
+ if (
+ state != IAttackRegistry.ContractState.CORRUPTED
+ && !_hasCorruptedScopedBinding()
+ ) revert InvalidOutcome();
@@ function claimExpired() external nonReentrant {
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ bool scopedCorrupted =
+ state == IAttackRegistry.ContractState.CORRUPTED || _hasCorruptedScopedBinding();
+ if (scopedCorrupted && riskWindowStart != 0) {
// existing moderator grace and CORRUPTED handling

A stronger design snapshots Binding Agreement identities when scope locks and defines an explicit
policy for later unbinding or rebinding. Add regression coverage for an original Agreement that
remains UNDER_ATTACK, post-commitment account removal, replacement corruption, and pool expiry.

Support

FAQs

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

Give us feedback!