Root + Impact
Description
Normal behavior
ConfidencePoolFactory is responsible for deploying non-upgradeable ConfidencePool clones. A sponsor calls createPool(), and the factory deploys a clone using the factory’s current poolImplementation, then initializes the new pool with the factory’s current safeHarborRegistry and defaultOutcomeModerator.
Existing pools are standalone clones and keep the values they received at initialization. The factory stores pools per agreement after deployment.
Specific issue
The factory owner can mutate governance-critical parameters that affect future pools:
safeHarborRegistry
poolImplementation
defaultOutcomeModerator
stake-token allowlist
These values are used when future pools are created.
As a result, future pools can be deployed with a different registry, implementation, moderator, or token policy than previous pools, even though they are created from the same factory. This creates a material governance dependency for sponsors and stakers interacting with new pools.
IBattleChainSafeHarborRegistry public safeHarborRegistry;
address public poolImplementation;
address public defaultOutcomeModerator;
mapping(address token => bool allowed) public override allowedStakeToken;
Relevant storage variables are defined globally in the factory
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
...
if (!allowedStakeToken[stakeToken]) revert StakeTokenNotAllowed();
...
pool = Clones.cloneDeterministic(poolImplementation, salt);
IConfidencePool(pool).initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress,
msg.sender,
accounts
);
}
createPool() uses the current factory values at the moment of pool creation.
function setSafeHarborRegistry(address newSafeHarborRegistry) external onlyOwner {
if (newSafeHarborRegistry == address(0)) revert ZeroAddress();
address old = address(safeHarborRegistry);
safeHarborRegistry = IBattleChainSafeHarborRegistry(newSafeHarborRegistry);
emit SafeHarborRegistryUpdated(old, newSafeHarborRegistry);
}
function setPoolImplementation(address newPoolImplementation) external onlyOwner {
if (newPoolImplementation == address(0)) revert ZeroAddress();
address old = poolImplementation;
poolImplementation = newPoolImplementation;
emit PoolImplementationUpdated(old, newPoolImplementation);
}
function setDefaultOutcomeModerator(address newDefaultOutcomeModerator) external onlyOwner {
if (newDefaultOutcomeModerator == address(0)) revert ZeroAddress();
address old = defaultOutcomeModerator;
defaultOutcomeModerator = newDefaultOutcomeModerator;
emit DefaultOutcomeModeratorUpdated(old, newDefaultOutcomeModerator);
}
function setStakeTokenAllowed(address token, bool allowed) external onlyOwner {
if (token == address(0)) revert ZeroAddress();
allowedStakeToken[token] = allowed;
emit StakeTokenAllowedUpdated(token, allowed);
}
All four mutation functions are owner-only but otherwise unrestricted.
Risk
Likelihood:
Reason 1 — The behavior occurs during ordinary admin operation
The factory owner has direct setter functions for registry, implementation, default moderator, and token allowlist. These setters do not require timelock delay, sponsor opt-in, or per-pool acknowledgement in this contract.
Reason 2 — New pools consume the latest factory state automatically
A sponsor creating a pool after the factory owner changes these values receives the latest factory configuration. The sponsor does not pass the moderator or registry directly to createPool(); those are taken from factory storage.
Impact:
Impact 1 — Future pools can use a different moderator
The moderator controls flagOutcome(), names the good-faith corrupted attacker, and can re-flag before claims start. Future pools created after setDefaultOutcomeModerator() are initialized with the new moderator.
Impact 2 — Future pools can use a different registry
The registry determines whether an agreement is valid and which attack-registry state the pool reads. Future pools created after setSafeHarborRegistry() are initialized with the new registry.
Impact 3 — Future pools can use a different implementation
createPool() clones the current poolImplementation. Therefore, after setPoolImplementation(), future pool addresses and behavior are based on the replacement implementation.
Impact 4 — Future pools can use newly allowlisted stake tokens
The factory gates pool creation through allowedStakeToken[stakeToken]. The owner can allow a new token, and future pools can then be created with that token.
Proof of Concept
The PoC added to the codebase is testPoC_factoryOwnerMutatesFuturePoolGovernance().
It performs the following steps:
Creates an initial pool using the original factory configuration.
Deploys replacement governance/configuration components:
new registry
new implementation
new moderator
new stake token
The factory owner mutates factory-wide configuration.
The test predicts the next deterministic clone address using the replacement implementation.
A second pool is created after the mutation.
The second pool is proven to use the new implementation address for deterministic prediction, new registry, new moderator, and new stake token.
The old pool is proven to retain the original registry, moderator, and token.
function testPoC_factoryOwnerMutatesFuturePoolGovernance() external {
uint256 expiry = block.timestamp + 31 days;
vm.prank(agreementOwner);
address oldPool = factory.createPool(address(agreement), address(token), expiry, ONE, recovery, _scope());
MockSafeHarborRegistry newRegistry = new MockSafeHarborRegistry();
newRegistry.setAgreementValid(address(agreement), true);
ConfidencePool newImplementation = new ConfidencePool();
address newModerator = makeAddr("newModerator");
MockERC20 newToken = new MockERC20();
factory.setSafeHarborRegistry(address(newRegistry));
factory.setPoolImplementation(address(newImplementation));
factory.setDefaultOutcomeModerator(newModerator);
factory.setStakeTokenAllowed(address(newToken), true);
bytes32 nextSalt = keccak256(abi.encode(address(agreement), uint256(1)));
address expectedNewPool =
Clones.predictDeterministicAddress(address(newImplementation), nextSalt, address(factory));
vm.prank(agreementOwner);
address newPool = factory.createPool(address(agreement), address(newToken), expiry + 1 days, ONE, recovery, _scope());
assertEq(newPool, expectedNewPool, "future pool uses replacement implementation for CREATE2 address");
assertEq(address(ConfidencePool(newPool).safeHarborRegistry()), address(newRegistry));
assertEq(ConfidencePool(newPool).outcomeModerator(), newModerator);
assertEq(address(ConfidencePool(newPool).stakeToken()), address(newToken));
assertEq(address(ConfidencePool(oldPool).safeHarborRegistry()), address(registry), "old pool keeps old registry");
assertEq(ConfidencePool(oldPool).outcomeModerator(), defaultModerator, "old pool keeps old moderator");
assertEq(address(ConfidencePool(oldPool).stakeToken()), address(token), "old pool keeps old token");
}
Recommended Mitigation
There are multiple possible mitigations depending on the intended governance model.
Option A — Timelock factory governance changes
Add a delay between scheduling and applying governance-sensitive factory updates.
- function setDefaultOutcomeModerator(address newDefaultOutcomeModerator) external onlyOwner {
- if (newDefaultOutcomeModerator == address(0)) revert ZeroAddress();
- address old = defaultOutcomeModerator;
- defaultOutcomeModerator = newDefaultOutcomeModerator;
- emit DefaultOutcomeModeratorUpdated(old, newDefaultOutcomeModerator);
- }
+ function scheduleDefaultOutcomeModerator(address newDefaultOutcomeModerator) external onlyOwner {
+ if (newDefaultOutcomeModerator == address(0)) revert ZeroAddress();
+ pendingDefaultOutcomeModerator = newDefaultOutcomeModerator;
+ pendingDefaultOutcomeModeratorEta = block.timestamp + GOVERNANCE_DELAY;
+ }
+
+ function executeDefaultOutcomeModeratorUpdate() external {
+ if (block.timestamp < pendingDefaultOutcomeModeratorEta) revert TimelockNotReady();
+ address old = defaultOutcomeModerator;
+ defaultOutcomeModerator = pendingDefaultOutcomeModerator;
+ delete pendingDefaultOutcomeModerator;
+ delete pendingDefaultOutcomeModeratorEta;
+ emit DefaultOutcomeModeratorUpdated(old, defaultOutcomeModerator);
+ }
Apply the same pattern to:
setSafeHarborRegistry()
setPoolImplementation()
setStakeTokenAllowed()
Option B — Add explicit pool-creation parameters for sponsor acknowledgement
Require the sponsor to pass the expected registry, implementation, and moderator into createPool(), then revert if the factory state differs.
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
- address[] calldata accounts
+ address[] calldata accounts,
+ address expectedSafeHarborRegistry,
+ address expectedPoolImplementation,
+ address expectedOutcomeModerator
) external whenNotPaused returns (address pool) {
+ if (expectedSafeHarborRegistry != address(safeHarborRegistry)) revert FactoryConfigChanged();
+ if (expectedPoolImplementation != poolImplementation) revert FactoryConfigChanged();
+ if (expectedOutcomeModerator != defaultOutcomeModerator) revert FactoryConfigChanged();
...
}
This does not remove governance power, but it prevents silent use of changed parameters during pool creation.
Option C — Emit stronger warning events and require frontend/indexer acknowledgement
The current setters already emit events.
If the protocol intentionally accepts this governance model, then the mitigation may be operational rather than code-level:
+ /// @notice Factory owner can change future-pool registry, implementation, moderator,
+ /// and token allowlist. Pool creators must read current factory values immediately
+ /// before calling createPool().
This should be paired with frontend warnings and monitoring.