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

Factory Moderator Updates Do Not Migrate Existing Pools

Author Revealed upon completion

Description

Confidence pools are deployed through ConfidencePoolFactory, which stores a mutable defaultOutcomeModerator. When a pool is created, the factory passes the current defaultOutcomeModerator into ConfidencePool.initialize(...), and the pool stores it as its own outcomeModerator.

The issue is that later calls to setDefaultOutcomeModerator() only update the factory default for future pools. Existing pools remain permanently pinned to the old moderator, with no pool-level moderator transfer or migration path. As a result, if the protocol rotates from moderator X to moderator Y and retires or loses access to X, old pools cannot be intentionally resolved by Y.

// ConfidencePoolFactory.sol
address public defaultOutcomeModerator;
function createPool(...) external whenNotPaused returns (address pool) {
...
IConfidencePool(pool).initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
@> defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress,
msg.sender,
accounts
);
...
}
function setDefaultOutcomeModerator(
address newDefaultOutcomeModerator
) external onlyOwner {
if (newDefaultOutcomeModerator == address(0)) revert ZeroAddress();
address old = defaultOutcomeModerator;
@> defaultOutcomeModerator = newDefaultOutcomeModerator;
emit DefaultOutcomeModeratorUpdated(old, newDefaultOutcomeModerator);
}
// ConfidencePool.sol
modifier onlyModerator() {
@> if (msg.sender != outcomeModerator) revert NotModerator();
_;
}
function initialize(
address agreement_,
address stakeToken_,
address safeHarborRegistry_,
address outcomeModerator_,
...
) external initializer {
...
@> outcomeModerator = outcomeModerator_;
...
}
function flagOutcome(
PoolStates.Outcome newOutcome,
bool goodFaith_,
address attacker_
) external onlyModerator {
...
}

Risk

Likelihood:

  • The protocol rotates the factory default moderator from X to Y after pools have already been deployed.

  • Operators or governance treat setDefaultOutcomeModerator() as a global moderator migration, while old pools remain pinned to X.

Impact:

  • Existing pools cannot be intentionally resolved when the old moderator is unavailable, because flagOutcome() reverts for the new moderator with NotModerator.

  • In corrupted-agreement cases, the pool must rely on the scope-blind expiry backstop. This can delay resolution for 180 days and can finalize as bad-faith CORRUPTED, sending the full pool to recoveryAddress with no good-faith attacker bounty path.

Proof of Concept

Attack Scenario

  1. A pool is created while the factory defaultOutcomeModerator is oldModerator.

  2. The pool snapshots oldModerator into its own outcomeModerator during initialize().

  3. The factory owner calls setDefaultOutcomeModerator(newModerator).

  4. The factory now reports newModerator as the default moderator, but the old pool still stores oldModerator.

  5. When newModerator tries to call flagOutcome() on the old pool, the call reverts with NotModerator.

  6. Only oldModerator can still resolve the old pool. If oldModerator is unavailable, the pool must wait for the expiry/backstop path instead of being intentionally moderated.

function test_oldPoolKeepsOldModeratorAfterFactoryDefaultUpdate() external {
address oldModerator = defaultModerator;
address newModerator = makeAddr("newModerator");
uint256 expiry = block.timestamp + 31 days;
vm.prank(agreementOwner);
address created = factory.createPool(
address(agreement),
address(token),
expiry,
ONE,
recovery,
_scope()
);
ConfidencePool oldPool = ConfidencePool(created);
// Factory owner rotates the default moderator.
factory.setDefaultOutcomeModerator(newModerator);
// The factory default changed for future pools.
assertEq(factory.defaultOutcomeModerator(), newModerator);
// But the already-created pool is still pinned to the old moderator.
assertEq(oldPool.outcomeModerator(), oldModerator);
// New moderator cannot resolve the old pool.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(newModerator);
vm.expectRevert(IConfidencePool.NotModerator.selector);
oldPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Old moderator is still required.
vm.prank(oldModerator);
oldPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(oldPool.outcome()), uint256(PoolStates.Outcome.SURVIVED));
}

Recommended Mitigation

Either clearly document that factory moderator updates are forward-looking only, or add an explicit pool-level moderator migration path controlled by governance/current moderator.

+ event OutcomeModeratorUpdated(address indexed oldModerator, address indexed newModerator);
+ function setOutcomeModerator(address newOutcomeModerator) external onlyOwner {
+ if (newOutcomeModerator == address(0)) revert ZeroAddress();
+ address old = outcomeModerator;
+ outcomeModerator = newOutcomeModerator;
+ emit OutcomeModeratorUpdated(old, newOutcomeModerator);
+ }

Support

FAQs

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

Give us feedback!