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

`outcomeModerator` is frozen at initialization with no rotation mechanism, locking staker funds for 210+ days if the moderator key is compromised

Author Revealed upon completion

Short Summary

outcomeModerator is set exactly once in initialize() and there is no setOutcomeModerator() function anywhere in the contract. If the DAO rotates its moderator address (e.g., post-incident key rotation), all existing live pools remain permanently tied to the old — potentially compromised — key. A compromised moderator key forces stakers into a 210+ day forced lockup before permissionless resolution is available.

Root Cause

// src/ConfidencePool.sol L207 — only write site
outcomeModerator = outcomeModerator_;
// No setter exists anywhere in the contract.
// grep result: outcomeModerator = ... → exactly 1 match (initialize)
// src/ConfidencePoolFactory.sol L146-151
// Factory has a setter — but only affects NEW pools, not existing ones:
function setDefaultOutcomeModerator(address newDefaultOutcomeModerator)
external onlyOwner
{
defaultOutcomeModerator = newDefaultOutcomeModerator;
emit DefaultOutcomeModeratorUpdated(old, newDefaultOutcomeModerator);
}
// ↑ Existing deployed pools are unaffected.
// src/ConfidencePool.sol L162-165 — modifier used for all sensitive actions
modifier onlyModerator() {
if (msg.sender != outcomeModerator) revert NotModerator();
_;
}
// flagOutcome() is the ONLY way to resolve SURVIVED/CORRUPTED;
// if outcomeModerator is compromised, the DAO cannot replace it.

Proper Description

The outcomeModerator is the DAO-controlled address that holds the authority to call flagOutcome() — the sole mechanism for setting SURVIVED or CORRUPTED outcomes on a pool. The protocol design assumes the DAO can always act through this address to resolve pools correctly.

The factory correctly supports key rotation for new pools via setDefaultOutcomeModerator. But there is no corresponding setter on the pool contract itself. Once deployed, each pool is permanently bound to the moderator address recorded at init time.

The real-world risk is a DAO key rotation event — common after security incidents, multisig threshold changes, or governance migrations. After such an event:

  • All newly created pools use the new, secure moderator address.

  • All existing live pools retain the old address — which may be a compromised or deprecated key.

If the old moderator key is compromised, the attacker controlling it can call flagOutcome(SURVIVED) on a CORRUPTED pool (returning principal to stakers, denying the attacker their bounty) or call flagOutcome(CORRUPTED, goodFaith=true, attacker=attacker_wallet) (redirecting the entire pool to themselves, if the registry is in CORRUPTED state). If the key is simply lost (not compromised), the only resolution path is the permissionless claimExpired() backstop, available only after expiry + MODERATOR_CORRUPTED_GRACE = up to 211+ days.

Internal Pre-condition

  1. Pool is deployed and the moderator key is later rotated, lost, or compromised at the DAO level.

  2. The pool is in an active state (UNRESOLVED, with riskWindowStart != 0 and registry in CORRUPTED state).

External Pre-condition

  1. The DAO performs a key rotation on their outcomeModerator address after pool deployment — a routine operational event.

  2. Alternatively: the old moderator key is compromised by an external attacker.

Details Attack Path

Scenario A — Key Loss (staker lockup):

  1. Pool created with outcomeModerator = DAOv1_key.

  2. DAO rotates to DAOv2_key via factory.setDefaultOutcomeModerator(DAOv2_key).

  3. Existing pool's outcomeModerator remains DAOv1_key.

  4. Agreement is CORRUPTED; moderator MUST flag outcome within MODERATOR_CORRUPTED_GRACE.

  5. DAO holds DAOv2_key only — DAOv1_key is discarded. No call to flagOutcome() is possible.

  6. claimExpired() before expiry + 180 days reverts with AgreementCorruptedAwaitingModerator.

  7. After expiry + 180 days, permissionless bad-faith CORRUPTED resolution fires — stakers lose all principal to recoveryAddress.

Scenario B — Key Compromise (attacker misflags outcome):

  1. Pool has 1.5M ONE staked. Agreement is CORRUPTED (breach confirmed, attacker named in good-faith path).

  2. Attacker compromises DAOv1_key.

  3. Attacker calls flagOutcome(SURVIVED, false, address(0)) — legal because registry is CORRUPTED and SURVIVED accepts CORRUPTED registry state.

  4. Stakers reclaim their principal (technically beneficial for stakers, but breach was real).

  5. Actual attacker (who performed the breach) receives no bounty — subverts the protocol's accountability mechanism.

Impact

  • Key loss: Stakers' capital locked for expiry duration + 180 days (typically 211+ days for a 31-day pool). After the lockup, funds sweep to recoveryAddress as bad-faith CORRUPTED — stakers lose all principal.

  • Key compromise: Attacker can misflag outcomes (SURVIVED when CORRUPTED or vice versa), subverting accountability payouts and potentially redirecting funds.

  • Operational: The DAO has no on-chain recourse for existing pools after a key rotation.

Proof of Concept

forge test --match-path "test/audit/L3*" -vvv
# [PASS] testPoC_L3_NoModeratorSetter()
# [PASS] testPoC_L3_KeyLossLocksPool()
# [PASS] testPoC_L3_NewPoolGetsNewModerator()
// test/audit/L3_ModeratorImmutable.t.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract L3_ModeratorImmutablePoC is BaseConfidencePoolTest {
function testPoC_L3_NoModeratorSetter() external view {
assertEq(pool.outcomeModerator(), moderator);
// No setOutcomeModerator() function exists — confirmed by absence
// of any matching selector in the ABI. Any call to it reverts with
// empty revert data (fallback).
}
function testPoC_L3_KeyLossLocksPool() external {
_stake(alice, 1_000_000 * ONE);
_stake(bob, 500_000 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
pool.pokeRiskWindow();
uint256 gracePeriodEnd = pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE();
// Moderator key is now "lost" — DAO rotated to a new key but this pool
// is stuck with the old address. No one holds the old key anymore.
// Before grace window: claimExpired reverts
vm.warp(pool.expiry() + 1);
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
// Only after 31d expiry + 180d grace = 211+ days can anyone resolve:
vm.warp(gracePeriodEnd + 1);
pool.claimExpired(); // auto-resolves as bad-faith CORRUPTED
// Stakers lost their 1.5M ONE — swept to recoveryAddress
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.CORRUPTED));
assertEq(pool.totalEligibleStake(), 1_500_000 * ONE,
"stakes not returned — lost to bad-faith sweep");
assertEq(pool.MODERATOR_CORRUPTED_GRACE(), 180 days);
assertGt(gracePeriodEnd, pool.expiry());
}
}

Mitigation

Add a setOutcomeModerator() function, gated to prevent abuse by the pool owner (sponsor). Since the outcomeModerator is a DAO-controlled role, the setter should ideally be restricted to the current moderator (a self-rotate) or require a two-step handoff rather than being onlyOwner:

// src/interfaces/IConfidencePool.sol
+ event OutcomeModeratorUpdated(address indexed oldModerator, address indexed newModerator);
// src/ConfidencePool.sol
+/// @notice Transfers the outcome moderator role to a new address.
+/// @dev Restricted to the current moderator (not the pool owner) to prevent
+/// the sponsor from replacing the DAO moderator with themselves and
+/// self-flagging a favorable outcome.
+function setOutcomeModerator(address newModerator) external {
+ if (msg.sender != outcomeModerator) revert NotModerator();
+ if (newModerator == address(0)) revert ZeroAddress();
+ address old = outcomeModerator;
+ outcomeModerator = newModerator;
+ emit OutcomeModeratorUpdated(old, newModerator);
+}

[!IMPORTANT]
If onlyOwner is used instead, the sponsor gains the ability to replace the DAO moderator and self-flag outcomes. The fix must restrict this setter to the current outcomeModerator or implement a factory-governed two-step handoff to maintain the trust boundary.

Support

FAQs

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

Give us feedback!