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

Moderator Cannot Flag CORRUPTED Outcome When Registry Reaches PRODUCTION (OOS scope) Before In-Scope CORRUPTED Is Recorded

Author Revealed upon completion

Summary

The flagOutcome() function requires the registry to show CORRUPTED state before the moderator can flag a CORRUPTED outcome. However, BattleChain's state machine is one-way terminal, once an agreement reaches PRODUCTION, it can never transition to CORRUPTED. This creates a scenario where in-scope contracts are breached but the moderator has no way to flag the correct outcome.

Root Cause

The contract blindly trusts the agreement-level registry state without accounting for the case where an OOS contract reaches terminal state first (PRODUCTION).

//flagOutcome()
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
// Hard requirement: registry MUST show CORRUPTED
// But if registry already shows PRODUCTION (one-way terminal)
// this ALWAYS reverts, even if IS contracts were breached
}

Code snippet:-
https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L347

Impact

Moderator CANNOT flag correct CORRUPTED outcome. Stakers incorrectly refunded principal + bonus (SURVIVED/EXPIRED).

Proof of Concept

Pool Setup:

Pool Scope (IS): [ContractA]

Agreement Scope: [ContractA, ContractB] (ContractB = OOS)

Total Staked: 10,000 tokens

Total Bonus: 1,000 tokens

Timeline:

Day 1: Agreement → UNDER_ATTACK

riskWindowStart = Day 1

Day 50: ContractB (OOS) successfully defended

Agreement registry → PRODUCTION ← ONE-WAY TERMINAL

Registry is now LOCKED at PRODUCTION forever

Day 51: ContractA (IS) gets exploited/corrupted

Attacker steals funds from in-scope contract

BUT: Registry already shows PRODUCTION
Cannot update to CORRUPTED (one-way state machine)

Day 52: Moderator knows ContractA was breached

Tries to flag correct outcome:

flagOutcome(CORRUPTED, true, attackerAddress)
Inside function:
state = _observePoolState() → PRODUCTION
CORRUPTED check:
if (state != CORRUPTED) revert InvalidOutcome()
→ PRODUCTION != CORRUPTED → REVERTS ✗

Result:

Moderator only option: flag SURVIVED (wrong outcome)

flagOutcome(SURVIVED, false, address(0)) ← forced wrong outcome
Stakers get: 10,000 + 1,000 = 11,000 tokens back ✗
Attacker: gets nothing ✗
Recovery: gets nothing ✗
In-scope breach completely unpunished.

Mitigation

Allow moderator to flag CORRUPTED outcome when registry shows PRODUCTION, relying on their off-chain judgement (same trust model already used for SURVIVED on CORRUPTED registry):

} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
if (!goodFaith_) {
if (attacker_ != address(0)) revert InvalidGoodFaithParams();
} else {
if (attacker_ == address(0)) revert InvalidGoodFaithParams();
}
// Allow CORRUPTED flag on both CORRUPTED and PRODUCTION registry states.
// PRODUCTION is included because BattleChain's one-way state machine can
// lock at PRODUCTION before an in-scope breach is recorded at registry level.
// Moderator's off-chain judgement is the source of truth (same trust model
// already applied to SURVIVED outcome on a CORRUPTED registry — see §8 DESIGN.md).
if (
state != IAttackRegistry.ContractState.CORRUPTED &&
state != IAttackRegistry.ContractState.PRODUCTION // ← ADD THIS
) {
revert InvalidOutcome();
}
}

Support

FAQs

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

Give us feedback!