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:
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).
Both results return principal and the applicable bonus to stakers even though the breached account was part of the scope against which they staked.
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.
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.
The code-level mitigation is non trivial, the flag outcome and expiry claim logic needs to be changed to account for such scenarios.
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.
Since the test uses all actual contract implementation we need to do the following:
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.
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";
contract RebindingPoCStakeToken is ERC20 {
constructor() ERC20("PoC Stake", "POC") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract RebindingPoCScopeAccount {
bool public breached;
function simulateBreach() external {
breached = true;
}
}
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();
}
function test_PoC_reboundLockedAccount_AProductionForcesPoolASurvived() external {
_narrowARebindAccount2ToBAndCorruptB();
_flagPoolBAsSurvivedAndAssertInversePathWorks();
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"
);
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
poolA.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
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");
}
function test_PoC_reboundLockedAccount_ANonTerminalAtExpiryForcesPoolAExpired() external {
_narrowARebindAccount2ToBAndCorruptB();
_flagPoolBAsSurvivedAndAssertInversePathWorks();
vm.prank(registryModerator);
attackRegistry.approveAttack(address(agreementA));
poolA.pokeRiskWindow();
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementA))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
poolA.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
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"
);
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 {
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),
address(0x2222)
)
)
)
);
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),
treasury
)
)
)
);
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 {
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"))
);
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();
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);
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));
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));
vm.prank(registryModerator);
attackRegistry.approveAttack(address(agreementB));
poolB.pokeRiskWindow();
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 {
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);
}
}