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

Pool fails to recognize corruption after a locked contract is rebound to another agreement

Author Revealed upon completion

Root + Impact

Description

The factory creates each pool with two pieces of information:

  • an external agreement whose state determines whether the pool survived or was corrupted;

  • a local list of contracts covered by that pool.

The pool locks its local list once the attack lifecycle begins, so depositors remain covered by exactly the contracts they selected. The factory only verifies that each contract is in the selected agreement when the pool is created.

The problem is that the pool’s local scope and the agreement’s scope can later diverge. For example, C can initially belong to Agreement A and be locked into the pool’s scope. After A’s commitment period expires, C can be removed from A and placed under Agreement B. The pool still records C as covered, but it continues reading only A’s state.

If C is later corrupted under B while A remains active, the pool cannot recognize the corruption. The moderator’s CORRUPTED resolution reverts, and expiry incorrectly resolves the pool as EXPIRED or SURVIVED, despite C being one of the pool’s permanently locked covered contracts.

function _getAgreementState() internal returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
// @> Always reads the originally selected agreement, A.
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}
...
// @> A must be CORRUPTED, even when the locally scoped account was corrupted under B.
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
...
// @> A remaining active incorrectly resolves the pool as EXPIRED.
outcome = PoolStates.Outcome.EXPIRED;

Risk

  • Medium impact: the pool’s settlement logic is materially broken and the intended corruption payout can be misdirected, but stakers generally receive the funds rather than suffering direct theft.

  • Medium likelihood: it requires specific conditions—scope migration plus B becoming corrupted while A remains active—but every step is supported protocol behavior. It does not require an exotic token or privileged exploit.

Proof of Concept

Save as:

test/unit/ConfidencePool.bindingMigration.t.sol

Run from the repository root:

forge test \
--match-path test/unit/ConfidencePool.bindingMigration.t.sol \
-vvv
// 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 {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract BindingMigrationAttackRegistry {
uint256 internal constant MIN_COMMITMENT = 7 days;
mapping(address => IAttackRegistry.ContractState) private states;
mapping(address => address) private bindings;
function requestUnderAttack(address agreement, address[] calldata accounts) external {
require(
IBindingMigrationAgreement(agreement).getCantChangeUntil()
>= block.timestamp + MIN_COMMITMENT
);
for (uint256 i; i < accounts.length; ++i) {
require(bindings[accounts[i]] == address(0));
bindings[accounts[i]] = agreement;
}
states[agreement] = IAttackRegistry.ContractState.ATTACK_REQUESTED;
}
function approveAttack(address agreement) external {
require(
states[agreement] == IAttackRegistry.ContractState.ATTACK_REQUESTED
);
states[agreement] = IAttackRegistry.ContractState.UNDER_ATTACK;
}
function markCorrupted(address agreement) external {
IAttackRegistry.ContractState state = states[agreement];
require(
state == IAttackRegistry.ContractState.UNDER_ATTACK
|| state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
);
states[agreement] = IAttackRegistry.ContractState.CORRUPTED;
}
function getAgreementState(address agreement)
external
view
returns (IAttackRegistry.ContractState)
{
return states[agreement];
}
function unregisterContractForExistingAgreement(
address account,
address agreement
) external {
if (bindings[account] == agreement) {
delete bindings[account];
}
}
function getAgreementForContract(address account)
external
view
returns (address)
{
return bindings[account];
}
}
contract BindingMigrationAgreement {
address public owner;
uint256 public cantChangeUntil;
BindingMigrationAttackRegistry internal registry;
constructor(
address owner_,
BindingMigrationAttackRegistry registry_,
uint256 cantChangeUntil_
) {
owner = owner_;
registry = registry_;
cantChangeUntil = cantChangeUntil_;
}
function getCantChangeUntil() external view returns (uint256) {
return cantChangeUntil;
}
function removeAccount(address account) external {
require(msg.sender == owner);
require(block.timestamp >= cantChangeUntil);
registry.unregisterContractForExistingAgreement(
account,
address(this)
);
}
}
contract ConfidencePoolBindingMigrationTest is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal constant C = address(0xC0FFEE);
address internal protocolOwner = makeAddr("protocolOwner");
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal attacker = makeAddr("attacker");
address internal alice = makeAddr("alice");
MockERC20 internal token;
MockSafeHarborRegistry internal safeHarborRegistry;
BindingMigrationAttackRegistry internal attackRegistry;
BindingMigrationAgreement internal agreementA;
BindingMigrationAgreement internal agreementB;
ConfidencePool internal pool;
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
safeHarborRegistry = new MockSafeHarborRegistry();
attackRegistry = new BindingMigrationAttackRegistry();
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
agreementA = new BindingMigrationAgreement(
protocolOwner,
attackRegistry,
BASE_TIMESTAMP + 7 days
);
agreementB = new BindingMigrationAgreement(
protocolOwner,
attackRegistry,
BASE_TIMESTAMP + 14 days
);
safeHarborRegistry.setAgreementValid(address(agreementA), true);
safeHarborRegistry.setAgreementValid(address(agreementB), true);
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory scope = new address[](1);
scope[0] = C;
pool.initialize(
address(agreementA),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
scope
);
}
function testBindingMigrationMakesBreachUnresolvable() external {
address[] memory aScope = new address[](1);
aScope[0] = C;
attackRegistry.requestUnderAttack(address(agreementA), aScope);
attackRegistry.approveAttack(address(agreementA));
pool.pokeRiskWindow();
token.mint(alice, 100 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
// C is removed from Agreement A after its commitment period.
vm.warp(BASE_TIMESTAMP + 7 days);
vm.prank(protocolOwner);
agreementA.removeAccount(C);
assertEq(
attackRegistry.getAgreementForContract(C),
address(0)
);
assertTrue(pool.isAccountInScope(C));
// C is then bound to Agreement B, which becomes corrupted.
address[] memory bScope = new address[](1);
bScope[0] = C;
attackRegistry.requestUnderAttack(address(agreementB), bScope);
attackRegistry.approveAttack(address(agreementB));
attackRegistry.markCorrupted(address(agreementB));
assertEq(
attackRegistry.getAgreementForContract(C),
address(agreementB)
);
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementA))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementB))),
uint256(IAttackRegistry.ContractState.CORRUPTED)
);
// The pool still reads Agreement A, so CORRUPTED is rejected.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
attacker
);
// The stale state from Agreement A causes expiry resolution.
vm.warp(pool.expiry());
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.EXPIRED)
);
assertTrue(pool.claimsStarted());
}
}
interface IBindingMigrationAgreement {
function getCantChangeUntil()
external
view
returns (uint256);
}

Recommended Mitigation

Require the agreement’s commitment window to last through the pool expiry, and enforce the same invariant whenever expiry changes.

if (!safeHarborRegistry.isAgreementValid(agreement)) {
revert InvalidAgreement();
}
+if (IAgreement(agreement).getCantChangeUntil() < expiry) {
+ revert InvalidAgreement();
}

Apply the equivalent check in ConfidencePool.initialize() and setExpiry(). This prevents an active Agreement A from being narrowed and rebound during the pool’s coverage period.

Support

FAQs

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

Give us feedback!