A confidence pool permanently locks its local account scope once the associated agreement leaves pre-attack staging. Corruption of a locked account should resolve the pool as CORRUPTED, distributing the pool to the named attacker or recovery address instead of rewarding stakers.
Every pool state observation remains keyed to the originally stored agreement. Corruption under the account's new Binding Agreement therefore cannot be reported: flagOutcome(CORRUPTED) requires the original agreement itself to be CORRUPTED. Once the original agreement reaches PRODUCTION, expiry mechanically resolves the pool as SURVIVED and pays all principal and bonus to stakers despite corruption of an account that remains in the pool's locked scope.
function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
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();
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.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(address(0), PoolStates.Outcome.SURVIVED, false, address(0));
}
}
}
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 {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract BindingAwareAttackRegistryMock {
mapping(address agreement => IAttackRegistry.ContractState state) internal _state;
mapping(address account => address agreement) internal _bindingAgreement;
function setAgreementState(address agreement, IAttackRegistry.ContractState state) external {
_state[agreement] = state;
}
function bind(address account, address agreement) external {
_bindingAgreement[account] = agreement;
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
return _state[agreement];
}
function getAgreementForContract(address account) external view returns (address) {
return _bindingAgreement[account];
}
}
contract LockedScopeDetachmentAuditTest is Test {
uint256 internal constant ONE = 1e18;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
address internal contributor = makeAddr("contributor");
address internal coveredAccount = address(0xC0FFEE);
address internal remainingAccount = address(0xBEEF);
MockERC20 internal token;
MockAgreement internal agreementA;
MockAgreement internal agreementB;
MockSafeHarborRegistry internal safeHarborRegistry;
BindingAwareAttackRegistryMock internal attackRegistry;
ConfidencePool internal pool;
function setUp() external {
vm.warp(1_750_000_000);
token = new MockERC20();
agreementA = new MockAgreement(address(this));
agreementB = new MockAgreement(address(this));
safeHarborRegistry = new MockSafeHarborRegistry();
attackRegistry = new BindingAwareAttackRegistryMock();
agreementA.setContractInScope(coveredAccount, true);
agreementA.setContractInScope(remainingAccount, true);
agreementB.setContractInScope(coveredAccount, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreementA), true);
safeHarborRegistry.setAgreementValid(address(agreementB), true);
attackRegistry.setAgreementState(
address(agreementA), IAttackRegistry.ContractState.NEW_DEPLOYMENT
);
attackRegistry.bind(coveredAccount, address(agreementA));
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory scope = new address[](1);
scope[0] = coveredAccount;
pool.initialize(
address(agreementA),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
scope
);
token.mint(alice, 100 * ONE);
vm.startPrank(alice);
token.approve(address(pool), type(uint256).max);
pool.stake(100 * ONE);
vm.stopPrank();
token.mint(contributor, 50 * ONE);
vm.startPrank(contributor);
token.approve(address(pool), type(uint256).max);
pool.contributeBonus(50 * ONE);
vm.stopPrank();
}
function testLockedAccountCanCorruptUnderBWhilePoolForAIsForcedToSurvive() external {
attackRegistry.setAgreementState(
address(agreementA), IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
assertTrue(pool.scopeLocked());
assertTrue(pool.isAccountInScope(coveredAccount));
vm.warp(block.timestamp + 7 days);
agreementA.setContractInScope(coveredAccount, false);
attackRegistry.bind(coveredAccount, address(agreementB));
attackRegistry.setAgreementState(
address(agreementB), IAttackRegistry.ContractState.CORRUPTED
);
attackRegistry.setAgreementState(
address(agreementA), IAttackRegistry.ContractState.PRODUCTION
);
assertTrue(pool.isAccountInScope(coveredAccount));
assertFalse(agreementA.isContractInScope(coveredAccount));
assertEq(
attackRegistry.getAgreementForContract(coveredAccount), address(agreementB)
);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, makeAddr("whitehat"));
vm.warp(pool.expiry());
uint256 balanceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED));
assertEq(token.balanceOf(alice) - balanceBefore, 150 * ONE);
assertEq(token.balanceOf(address(pool)), 0);
}
}
The upstream removal/rebinding sequence was also executed successfully against the real vendored BattleChain contracts at commit fde1b2a.
Require the agreement's non-reducible commitment period to cover the entire pool term during initialization and every permitted expiry update. The agreement owner can call extendCommitmentWindow() before creating the pool. Once the pool exists, this invariant prevents a locked account from being removed or unregistered before pool expiry.
+ error AgreementCommitmentTooShort(uint256 commitmentEnd, uint256 poolExpiry);
function initialize(
address agreement_,
address stakeToken_,
address safeHarborRegistry_,
address outcomeModerator_,
uint256 expiry_,
uint256 minStake_,
address recoveryAddress_,
address owner_,
address[] calldata accounts
) external initializer {
// ...
- if (!IBattleChainSafeHarborRegistry(safeHarborRegistry_).isAgreementValid(agreement_)) {
+ IBattleChainSafeHarborRegistry registry_ =
+ IBattleChainSafeHarborRegistry(safeHarborRegistry_);
+ if (!registry_.isAgreementValid(agreement_)) {
revert InvalidAgreement();
}
+ uint256 commitmentEnd = IAgreement(agreement_).getCantChangeUntil();
+ if (commitmentEnd < expiry_) {
+ revert AgreementCommitmentTooShort(commitmentEnd, expiry_);
+ }
// ...
}
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();
+ uint256 commitmentEnd = IAgreement(agreement).getCantChangeUntil();
+ if (commitmentEnd < newExpiry) {
+ revert AgreementCommitmentTooShort(commitmentEnd, newExpiry);
+ }
uint256 oldExpiry = expiry;
expiry = uint32(newExpiry);
emit ExpiryUpdated(oldExpiry, newExpiry);
}