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

Original-Agreement State Gating Ignores the Pool’s Locked Scope, Going Against the Documented Workflow and Misclassifying In-Scope Breaches

Author Revealed upon completion

Description

The protocol documents the pool's locked scope as a fixed commitment that is isolated from subsequent changes to the underlying agreement. According to the two protocol documentation references:

  1. Excerpt from protocol-readme.md Trust Assumptions

    **Scope lock isolates stakers from agreement-level changes.** Once the registry leaves pre-attack staging, sponsor changes to the underlying agreement — adding accounts, **narrowing scope**, swapping the agreement's recovery address — don't alter what this pool covers. Stakers are exposed to exactly what they signed up for at deposit time.
  2. Excerpt from DESIGN.md point 8

    **Residual trade-off:** if the sponsor *narrows* the agreement, the pool's locked scope may
    reference accounts no longer in the agreement — but the pool's own commitment to stakers remains
    the binding source of truth.

It is clear that if a scope (after scope lock) of an agreement is narrowed, it does not alter what the pool covers. The pool's scope commitment remains the binding source of truth. Stakers are exposed to exactly what they signed up for at deposit time. This scope changes after a scope-lock is therefore the reason the final outcome for a pool is completely dependent on the moderator's off-chain judgement based on the committed scope and can decouple from the registry outcome as explained in the DESIGN docs point 8. But that is not the case under certain scenarios (Refer to the PoC comments for a more detailed explanation).

This commitment is stored independently in _scopeAccounts and isAccountInScope. However, the scope is only validated against the agreement when it is initially set or replaced. It is not used to determine whether the moderator is authorized to report a later CORRUPTED outcome. Instead, the pool stores one immutable agreement address and _getAgreementState() always returns the state of that original agreement:

function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}

flagOutcome() then requires this specific agreement to be CORRUPTED before the trusted moderator can flag the pool as CORRUPTED:

if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();

This requirement breaks the fixed-scope commitment when an account is removed from the original agreement and rebound to another agreement. There is one global AttackRegistry, but it maintains independent state for each agreement and a current contract => agreement binding. Consider a pool tied to Agreement A whose locked scope contains Account X:

  1. The pool accepts stake and permanently locks Account X in its local scope.

  2. Agreement A removes X. This updates A's scope and unregisters X from A in the AttackRegistry, but it does not remove X from the pool's locked scope as intended by the design.

  3. X is subsequently added and registered under Agreement B.

  4. X is successfully breached while operating under B, causing Agreement B to become CORRUPTED.

  5. Agreement A remains nonterminal or reaches PRODUCTION, because the breached account is no longer part of A's scope.

At that point, the moderator cannot resolve the pool as CORRUPTED, despite the breach occurring in a contract that remains inside the pool’s locked commitment. The moderator's attempt to call flagOutcome(CORRUPTED, ...) reverts with InvalidOutcome whenever A is nonterminal or PRODUCTION.

The final pool result depends exclusively on A:

State of Agreement A State of Agreement B after X is breached Pool result
PRODUCTION CORRUPTED The moderator cannot flag CORRUPTED; flagOutcome(SURVIVED, ...) or claimExpired() resolves the pool as SURVIVED
Any nonterminal state at pool expiry CORRUPTED The moderator cannot flag CORRUPTED; claimExpired() resolves the pool as EXPIRED

Both results return principal and the applicable bonus to stakers even though the breached account was part of the scope against which they staked.

The reverse disagreement is explicitly supported: if Agreement A becomes CORRUPTED because an account outside the pool's locked subset was breached, the moderator can flag the pool as SURVIVED. The implementation therefore supports an agreement-level corruption being downgraded based on the pool's narrower scope, but it does not support an agreement-level survival or nonterminal state being upgraded to CORRUPTED when an account preserved by the pool's locked scope is breached after rebinding. The moderator is trusted to identify the breach surface, but the original-agreement state gate prevents the moderator from applying that judgement in this direction going against what the protocol promises during commitment.

Impact

Stakers recover their principal and may receive the sponsor-funded bonus despite a successful breach of an account that remains inside the pool's documented locked coverage. A good-faith attacker cannot be named for the pool's attacker bounty, and a bad-faith corruption cannot transfer the pool's stake and bonus to the recovery address. Later agreement-scope changes therefore alter the economic exposure that the protocol promises will remain fixed after scope lock as per the docs.

Likelihood is also High, the AttackRegistry offers functionality for Agreements to change their scope actively while they are under attack.

Root Cause

The pool preserves its committed account list but does not use that commitment as an independent input to resolution. _getAgreementState() reads only the immutable original agreement, and flagOutcome(CORRUPTED, ...) requires that agreement itself to be CORRUPTED. The pool never considers the current agreement binding or terminal state of accounts retained in its locked scope, leaving the trusted moderator unable to report an in-scope breach after one of those accounts is removed and rebound.

Recommended Mitigation

The code-level mitigation is non trivial, the flag outcome and expiry claim logic needs to be changed to account for such scenarios.

I think the most trivial way to counter this would be flagOutcome for a moderator must not be dependent on the linked registry state, and claimExpired should be permisson gated and non-dependent on the linked registry state as well. It should allow a moderator to decide the final outcome after expiry. Since moderator is considered trusted.

Or if this is intended then, which is clear is not from the wording of docs regarding narrowed scope, change the Docs to specify this clearly.

PoC

Setup

Since the test uses all actual contract implementation we need to do the following:

  • ConfidencePool is pinned to Solidity 0.8.26.

  • The vendored Safe Harbor implementations are pinned to Solidity 0.8.34.

Solidity cannot import contracts pinned to both exact compiler versions into one compilation unit. The test therefore deploys the real Safe Harbor contracts from their Solidity 0.8.34 build artifacts and interacts with them through the vendored interfaces. ConfidencePool compiles normally under Solidity 0.8.26.

foundry.toml was updated to grant the test read-only access to the Safe Harbor build artifacts:

+ fs_permissions = [
+ { access = "read", path = "./lib/battlechain-safe-harbor-contracts/out" },
+ ]

Run

# Compile the real Safe Harbor contracts with Solidity 0.8.34
forge build --root lib/battlechain-safe-harbor-contracts
# Compile ConfidencePool with 0.8.26 and run the PoC
forge test --match-path test/poc/LockedScopeRebinding.t.sol -vvv

PoC Code

Save the following file as test/poc/LockedScopeRebinding.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.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 {IBattleChainDeployer} from "@battlechain/interface/IBattleChainDeployer.sol";
import {IBattleChainSafeHarborRegistry} from "@battlechain/interface/IBattleChainSafeHarborRegistry.sol";
import {
AgreementDetails,
Account as BCAccount,
Chain as BCChain,
Contact,
BountyTerms,
ChildContractScope,
IdentityRequirements
} from "@battlechain/types/AgreementTypes.sol";
/// @dev Plain standard ERC20 used only as the stake asset. No protocol dependency is mocked.
contract RebindingPoCStakeToken is ERC20 {
constructor() ERC20("PoC Stake", "POC") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
/// @dev Concrete BattleChain-deployed account used to identify which committed account was breached.
/// Safe Harbor corruption is ultimately marked by the attack moderator, so the boolean is only
/// an explicit, inspectable marker for the breach surface in this PoC. Just to visualize a whitehat
/// breaching the account for verbosiity
contract RebindingPoCScopeAccount {
bool public breached;
function simulateBreach() external {
breached = true;
}
}
/// @dev Admin methods present on the real 0.8.34 implementations but omitted from downstream interfaces cos unnecessary.
interface IAttackRegistryPoCAdmin {
function setBattleChainDeployer(address newDeployer) external;
}
interface ISafeHarborRegistryPoCAdmin {
function setAttackRegistry(address attackRegistry) external;
}
/**
* @notice End-to-end PoC for the locked-scope narrowing/rebinding resolution mismatch.
*
* Compiler note:
* - ConfidencePool is pinned to Solidity 0.8.26.
* - The vendored Safe Harbor implementations are pinned to Solidity 0.8.34.
*
* Solidity cannot import both exact compiler versions into one compilation unit. The test therefore
* deploys bytecode from the vendored suite's real 0.8.34 build artifacts and calls it through the
* vendored interfaces, while the ConfidencePool contracts compile normally at 0.8.26. Before running:
*
* forge build --root lib/battlechain-safe-harbor-contracts
* forge test --match-path test/poc/LockedScopeRebinding.t.sol -vvv
*
* No Agreement, AttackRegistry, SafeHarborRegistry, factory, or pool mock is used.
*/
contract LockedScopeRebindingPoC is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant STAKE = 100 * ONE;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
uint256 internal constant AGREEMENT_COMMITMENT = 7 days;
uint256 internal constant POOL_TERM = 40 days;
string internal constant BATTLECHAIN_CAIP2 = "eip155:325";
string internal constant SAFE_HARBOR_REGISTRY_ARTIFACT =
"lib/battlechain-safe-harbor-contracts/out/BattleChainSafeHarborRegistry.sol/BattleChainSafeHarborRegistry.json";
string internal constant AGREEMENT_FACTORY_ARTIFACT =
"lib/battlechain-safe-harbor-contracts/out/AgreementFactory.sol/AgreementFactory.json";
string internal constant ATTACK_REGISTRY_ARTIFACT =
"lib/battlechain-safe-harbor-contracts/out/AttackRegistry.sol/AttackRegistry.json";
string internal constant BATTLECHAIN_DEPLOYER_ARTIFACT =
"lib/battlechain-safe-harbor-contracts/out/BattleChainDeployer.sol/BattleChainDeployer.json";
address internal safeHarborOwner = makeAddr("safeHarborOwner");
address internal registryModerator = makeAddr("registryModerator");
address internal agreementOwner = makeAddr("agreementOwner");
address internal poolModerator = makeAddr("poolModerator");
address internal staker = makeAddr("staker");
address internal whitehat = makeAddr("whitehat");
address internal recovery = makeAddr("recovery");
address internal treasury = makeAddr("treasury");
IBattleChainSafeHarborRegistry internal safeHarborRegistry;
IAgreementFactory internal agreementFactory;
IAttackRegistry internal attackRegistry;
IBattleChainDeployer internal battleChainDeployer;
ConfidencePoolFactory internal confidencePoolFactory;
RebindingPoCStakeToken internal stakeToken;
IAgreement internal agreementA;
IAgreement internal agreementB;
ConfidencePool internal poolA;
ConfidencePool internal poolB;
address internal account1;
address internal account2;
address internal account3;
address internal account4;
uint256 internal commitmentEndsAt;
function setUp() public {
vm.warp(BASE_TIMESTAMP);
_deployRealSafeHarborStack();
_deployRealConfidencePoolStack();
_deployScopeAccountsThroughBattleChainDeployer();
_createAndRegisterAgreements();
_createPoolsAndLockTheirScopesWithStake();
}
/// @notice Branch 1: Agreement A reaches PRODUCTION, so Pool A is forced to SURVIVED even
/// though its sole locked account was breached and Agreement B is CORRUPTED.
function test_PoC_reboundLockedAccount_AProductionForcesPoolASurvived() external {
_narrowARebindAccount2ToBAndCorruptB();
// B is CORRUPTED because newly-added Account 2 was breached. Pool B committed only to
// Accounts 3 and 4 before Account 2 was added, so the trusted moderator correctly uses
// the documented out-of-pool-scope path and flags Pool B as SURVIVED.
_flagPoolBAsSurvivedAndAssertInversePathWorks();
// Account 1, the only account left in Agreement A, survives. The registry moderator moves
// A from ATTACK_REQUESTED to terminal PRODUCTION. Pool A still commits solely to breached
// Account 2, but its resolver reads Agreement A rather than Account 2's current Agreement B.
vm.prank(registryModerator);
attackRegistry.instantPromote(address(agreementA));
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementA))),
uint256(IAttackRegistry.ContractState.PRODUCTION),
"Agreement A should record survival of its narrowed scope"
);
// This is the failing branch: the moderator knows Account 2 was the breach surface and it
// is still Pool A's sole locked account, but the CORRUPTED outcome is rejected because A,
// the immutable pool agreement, is PRODUCTION. Agreement B's CORRUPTED state is ignored.
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
poolA.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
// The only manual terminal outcome allowed for Pool A is SURVIVED. Both Pool A and Pool B
// therefore return principal to the staker even though Account 2 was successfully breached.
vm.prank(poolModerator);
poolA.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(poolA.outcome()), uint256(PoolStates.Outcome.SURVIVED));
vm.startPrank(staker);
poolA.claimSurvived();
poolB.claimSurvived();
vm.stopPrank();
assertEq(stakeToken.balanceOf(staker), 2 * STAKE, "both principals are returned");
assertEq(stakeToken.balanceOf(whitehat), 0, "Pool A cannot expose its good-faith bounty path");
assertEq(stakeToken.balanceOf(recovery), 0, "Pool A cannot expose its bad-faith recovery path");
}
/// @notice Branch 2: Agreement A remains UNDER_ATTACK through pool expiry, so Pool A is forced
/// to EXPIRED even though Account 2 was breached and Agreement B is CORRUPTED.
function test_PoC_reboundLockedAccount_ANonTerminalAtExpiryForcesPoolAExpired() external {
_narrowARebindAccount2ToBAndCorruptB();
_flagPoolBAsSurvivedAndAssertInversePathWorks();
// Keep A nonterminal by approving its remaining Account 1 for attack mode. UNDER_ATTACK is
// checked before the registry's deadline-based promotion, so it remains nonterminal even
// after Pool A expires. Poking records Pool A's genuine risk window without changing scope.
vm.prank(registryModerator);
attackRegistry.approveAttack(address(agreementA));
poolA.pokeRiskWindow();
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementA))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
// Before expiry, the trusted moderator is already unable to record Account 2's in-scope
// breach because Pool A sees only A=UNDER_ATTACK instead of B=CORRUPTED.
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
poolA.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
// At expiry, claimExpired reads A=UNDER_ATTACK and mechanically chooses EXPIRED. It never
// checks that Pool A's locked Account 2 is currently bound to CORRUPTED Agreement B.
vm.warp(poolA.expiry());
vm.prank(staker);
poolA.claimExpired();
assertEq(uint256(poolA.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementB))),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"Agreement B remains proof of Account 2's breach"
);
// Mechanical EXPIRED resolution starts claims and permanently closes the moderator path.
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
poolA.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.prank(staker);
poolB.claimSurvived();
assertEq(stakeToken.balanceOf(staker), 2 * STAKE, "SURVIVED plus EXPIRED return both principals");
assertEq(stakeToken.balanceOf(whitehat), 0, "breached locked scope produces no attacker bounty");
assertEq(stakeToken.balanceOf(recovery), 0, "breached locked scope produces no recovery sweep");
}
function _deployRealSafeHarborStack() internal {
// The registry is deployed first with non-zero bootstrap pointers, then wired to the real
// factory and AttackRegistry proxies below. This mirrors their owner-controlled setup flow.
string[] memory validChains = new string[](1);
validChains[0] = BATTLECHAIN_CAIP2;
address registryImplementation = vm.deployCode(SAFE_HARBOR_REGISTRY_ARTIFACT);
safeHarborRegistry = IBattleChainSafeHarborRegistry(
address(
new ERC1967Proxy(
registryImplementation,
abi.encodeWithSignature(
"initialize(address,string[],address,address)",
safeHarborOwner,
validChains,
address(0x1111), //placeholder for agreement factory
address(0x2222) // for attack registry
)
)
)
);
address agreementFactoryImplementation = vm.deployCode(AGREEMENT_FACTORY_ARTIFACT);
agreementFactory = IAgreementFactory(
address(
new ERC1967Proxy(
agreementFactoryImplementation,
abi.encodeWithSignature(
"initialize(address,address,string)",
safeHarborOwner,
address(safeHarborRegistry),
BATTLECHAIN_CAIP2
)
)
)
);
address attackRegistryImplementation = vm.deployCode(ATTACK_REGISTRY_ARTIFACT);
attackRegistry = IAttackRegistry(
address(
new ERC1967Proxy(
attackRegistryImplementation,
abi.encodeWithSignature(
"initialize(address,address,address,address,address,address)",
safeHarborOwner,
registryModerator,
address(safeHarborRegistry),
address(agreementFactory),
address(0x3333), //placeholder for battlechain deployer
treasury
)
)
)
);
// Deploy the real BattleChainDeployer against the now-known AttackRegistry proxy, then
// replace the bootstrap pointer and complete all registry back-pointers.
battleChainDeployer =
IBattleChainDeployer(vm.deployCode(BATTLECHAIN_DEPLOYER_ARTIFACT, abi.encode(address(attackRegistry))));
vm.startPrank(safeHarborOwner);
IAttackRegistryPoCAdmin(address(attackRegistry)).setBattleChainDeployer(address(battleChainDeployer));
safeHarborRegistry.setAgreementFactory(address(agreementFactory));
ISafeHarborRegistryPoCAdmin(address(safeHarborRegistry)).setAttackRegistry(address(attackRegistry));
vm.stopPrank();
}
function _deployRealConfidencePoolStack() internal {
stakeToken = new RebindingPoCStakeToken();
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
confidencePoolFactory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(poolImplementation), poolModerator)
)
)
)
);
confidencePoolFactory.setStakeTokenAllowed(address(stakeToken), true);
}
function _deployScopeAccountsThroughBattleChainDeployer() internal {
// All four accounts are real deployments through BattleChainDeployer. Deployment records
// agreementOwner as their authorized owner, allowing the verified requestUnderAttack path.
vm.startPrank(agreementOwner);
account1 = battleChainDeployer.deployCreate(type(RebindingPoCScopeAccount).creationCode);
account2 = battleChainDeployer.deployCreate(type(RebindingPoCScopeAccount).creationCode);
account3 = battleChainDeployer.deployCreate(type(RebindingPoCScopeAccount).creationCode);
account4 = battleChainDeployer.deployCreate(type(RebindingPoCScopeAccount).creationCode);
vm.stopPrank();
assertEq(attackRegistry.getAuthorizedOwner(account1), agreementOwner);
assertEq(attackRegistry.getAuthorizedOwner(account2), agreementOwner);
assertEq(attackRegistry.getAuthorizedOwner(account3), agreementOwner);
assertEq(attackRegistry.getAuthorizedOwner(account4), agreementOwner);
}
function _createAndRegisterAgreements() internal {
address[] memory scopeA = new address[](2);
scopeA[0] = account1;
scopeA[1] = account2;
address[] memory scopeB = new address[](2);
scopeB[0] = account3;
scopeB[1] = account4;
vm.startPrank(agreementOwner);
agreementA = IAgreement(
agreementFactory.create(_agreementDetails("Agreement A", scopeA), agreementOwner, keccak256("A"))
);
agreementB = IAgreement(
agreementFactory.create(_agreementDetails("Agreement B", scopeB), agreementOwner, keccak256("B"))
);
// Seven days is the real AttackRegistry minimum. Removal happens exactly when this window
// ends, still before the 14-day ATTACK_REQUESTED auto-promotion deadline.
commitmentEndsAt = block.timestamp + AGREEMENT_COMMITMENT;
agreementA.extendCommitmentWindow(commitmentEndsAt);
agreementB.extendCommitmentWindow(commitmentEndsAt);
attackRegistry.requestUnderAttack(address(agreementA));
attackRegistry.requestUnderAttack(address(agreementB));
vm.stopPrank();
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementA))),
uint256(IAttackRegistry.ContractState.ATTACK_REQUESTED)
);
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementB))),
uint256(IAttackRegistry.ContractState.ATTACK_REQUESTED)
);
}
function _createPoolsAndLockTheirScopesWithStake() internal {
address[] memory poolAScope = new address[](1);
poolAScope[0] = account2;
address[] memory poolBScope = new address[](2);
poolBScope[0] = account3;
poolBScope[1] = account4;
uint256 expiry = block.timestamp + POOL_TERM;
vm.startPrank(agreementOwner);
poolA = ConfidencePool(
confidencePoolFactory.createPool(
address(agreementA), address(stakeToken), expiry, ONE, recovery, poolAScope
)
);
poolB = ConfidencePool(
confidencePoolFactory.createPool(
address(agreementB), address(stakeToken), expiry, ONE, recovery, poolBScope
)
);
vm.stopPrank();
// Both agreements are already ATTACK_REQUESTED. Each stake therefore observes a state past
// pre-attack staging and permanently locks its pool-local scope.
stakeToken.mint(staker, 2 * STAKE);
vm.startPrank(staker);
stakeToken.approve(address(poolA), STAKE);
poolA.stake(STAKE);
stakeToken.approve(address(poolB), STAKE);
poolB.stake(STAKE);
vm.stopPrank();
assertTrue(poolA.scopeLocked());
assertTrue(poolB.scopeLocked());
assertTrue(poolA.isAccountInScope(account2));
assertTrue(poolB.isAccountInScope(account3));
assertTrue(poolB.isAccountInScope(account4));
assertFalse(poolB.isAccountInScope(account2));
}
function _narrowARebindAccount2ToBAndCorruptB() internal {
vm.warp(commitmentEndsAt);
// Narrow Agreement A from [1,2] to [1]. Agreement's real sync hook deletes Account 2's
// AttackRegistry binding, but Pool A's locked [2] commitment remains unchanged.
string[] memory removed = new string[](1);
removed[0] = _addressToString(account2);
vm.prank(agreementOwner);
agreementA.removeAccounts(BATTLECHAIN_CAIP2, removed);
assertFalse(agreementA.isContractInScope(account2));
assertTrue(poolA.isAccountInScope(account2));
assertEq(attackRegistry.getAgreementForContract(account2), address(0));
// Expand Agreement B from [3,4] to [3,4,2]. Agreement B's sync hook now binds Account 2
// to B, while Pool B correctly retains only its already-locked [3,4] scope.
BCAccount[] memory added = new BCAccount[](1);
added[0] = BCAccount({accountAddress: _addressToString(account2), childContractScope: ChildContractScope.None});
vm.prank(agreementOwner);
agreementB.addAccounts(BATTLECHAIN_CAIP2, added);
assertTrue(agreementB.isContractInScope(account2));
assertEq(attackRegistry.getAgreementForContract(account2), address(agreementB));
assertTrue(poolA.isAccountInScope(account2));
assertFalse(poolB.isAccountInScope(account2));
// B enters active attack mode, Pool B observes its risk window, and Account 2 is breached.
// The Agreement B attack moderator then records the real terminal CORRUPTED state.
vm.prank(registryModerator);
attackRegistry.approveAttack(address(agreementB));
poolB.pokeRiskWindow();
// Just to visualize a whitehat attack.
vm.prank(whitehat);
RebindingPoCScopeAccount(account2).simulateBreach();
assertTrue(RebindingPoCScopeAccount(account2).breached());
vm.prank(agreementOwner);
attackRegistry.markCorrupted(address(agreementB));
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementB))),
uint256(IAttackRegistry.ContractState.CORRUPTED)
);
}
function _flagPoolBAsSurvivedAndAssertInversePathWorks() internal {
// Account 2 is inside Agreement B but outside Pool B's locked [3,4] subset. The moderator's
// off-chain breach-surface judgement is therefore SURVIVED, which the pool accepts while B
// is CORRUPTED. This proves the documented agreement-CORRUPTED -> pool-SURVIVED direction.
vm.prank(poolModerator);
poolB.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(poolB.outcome()), uint256(PoolStates.Outcome.SURVIVED));
assertEq(uint256(poolA.outcome()), uint256(PoolStates.Outcome.UNRESOLVED));
}
function _agreementDetails(string memory name, address[] memory scope)
internal
pure
returns (AgreementDetails memory details)
{
BCAccount[] memory accounts = new BCAccount[](scope.length);
for (uint256 i; i < scope.length; ++i) {
accounts[i] =
BCAccount({accountAddress: _addressToString(scope[i]), childContractScope: ChildContractScope.None});
}
BCChain[] memory chains = new BCChain[](1);
chains[0] = BCChain({
accounts: accounts,
assetRecoveryAddress: "0x000000000000000000000000000000000000dEaD",
caip2ChainId: BATTLECHAIN_CAIP2
});
Contact[] memory contacts = new Contact[](1);
contacts[0] = Contact({name: "PoC", contact: "poc@example.com"});
details = AgreementDetails({
protocolName: name,
chains: chains,
contactDetails: contacts,
bountyTerms: BountyTerms({
bountyPercentage: 10,
bountyCapUsd: 1_000_000,
retainable: false,
identity: IdentityRequirements.Anonymous,
diligenceRequirements: "none",
aggregateBountyCapUsd: 2_000_000
}),
agreementURI: "ipfs://locked-scope-rebinding-poc"
});
}
function _addressToString(address value) 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(value) >> (8 * (19 - i)) >> 4) & 0xf];
encoded[3 + i * 2] = alphabet[uint8(uint160(value) >> (8 * (19 - i))) & 0xf];
}
return string(encoded);
}
}

What the attack does:

  • Create two agreements with scope [1, 2] and [3, 4] respectively, register them to their seperate pools with Pool A for Agreement A scope as [2] and Pool B for Agreement B scope as [3, 4].

  • Start stake for both pools which lock the scope.

  • later Remove 2 from Agreement A and add to B, while pool scopes are locked. Then hack 2 marking Agreement B as corrupted but it allows the pool moderator to mark PoolB as survived because 2 was breached as out-of-commitment scope which is correct. But the opposite, pool A cannot be marked as corrupted even tho the breached account was the only in-commitment-scope for the pool. Going against the protocol docs promise of narrowing scope does not alterr what this pool covers.

Support

FAQs

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

Give us feedback!