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

OutcomeFlagged can be re-emitted with a different outcome before claim finality, causing off-chain systems that treat the first event as final to index an incorrect result

Author Revealed upon completion

Root + Impact

Description

  • Normal Behavior

    The protocol intentionally allows the moderator to re-flag a pool outcome before any participant has started claiming. This allows the moderator to fix an incorrect outcome or attacker address before funds move. The design document explicitly states that flagOutcome may be re-flagged before the first claim.

    Finality is tied to claimsStarted, not to the first OutcomeFlagged event. The design document describes claimsStarted as a value-movement finality latch.


  • Specific Issue

    The OutcomeFlagged event does not indicate whether the emitted outcome is final or still re-flaggable. The event only includes:

    event OutcomeFlagged(address indexed moderator, PoolStates.Outcome outcome, bool goodFaith, address attacker);

Because flagOutcome() emits the same event for the first flag and for later re-flags, an off-chain consumer that treats the first event as the final settlement result can become permanently wrong unless it explicitly tracks all later OutcomeFlagged events and the claimsStarted finality condition.

The on-chain logic is not broken. The issue is that the event semantics are easy to misinterpret as final settlement when the protocol’s actual finality condition is separate.

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
// Re-flag allowed pre-claim so the moderator can fix a typo'd outcome / attacker before
// any participant locks in the wrong distribution. The window closing on the FIRST claim
// (`claimsStarted`) is by design — a value-movement finality latch, not a front-runnable
// moderator privilege. See docs/DESIGN.md (re-flag window).
// @> Re-flagging is allowed when outcome is already set as long as claimsStarted is false.
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
...
// @> Existing outcome is overwritten.
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
...
// @> Same event is emitted for both initial flag and corrected/re-flagged outcome.
// @> Event does not tell off-chain systems whether this is final.
emit OutcomeFlagged(msg.sender, newOutcome, goodFaith_, attacker_);
}

The re-flag gate is based on claimsStarted, not on whether an outcome was already emitted.

The outcome fields are overwritten on every allowed flagOutcome() call.

The same OutcomeFlagged event is emitted each time.

The event interface itself has no isFinal, version, supersedes, or claimsStarted field.

Risk

Likelihood:

Likelihood: Medium

Reason 1 — Re-flagging is an intended moderator workflow

The design document explicitly permits re-flagging before the first claim so the moderator can fix a typo or incorrect result.

This is not an edge case or exploit-only path. It is a supported correction flow.

Reason 2 — The emitted event name looks settlement-like

The event is named OutcomeFlagged, and it includes the pool outcome, good-faith flag, and attacker address.

An off-chain system can reasonably treat this as the settlement signal unless it also understands the separate finality condition.

Reason 3 — Finality is not encoded in the event

The finality latch is claimsStarted, but OutcomeFlagged does not include claimsStarted or any equivalent finality field.

Impact:

  • Impact: Medium / High integration risk

    Impact 1 — Incorrect settlement state in indexers/frontends

    An indexer that stores the first OutcomeFlagged event as final may show SURVIVED even after the moderator re-flags to CORRUPTED, or vice versa.

    The existing test suite proves that the outcome can move from SURVIVED to CORRUPTED before claims start.

    Impact 2 — Incorrect claim guidance

    A frontend may tell stakers to call claimSurvived() after observing a first SURVIVED event. If the moderator later re-flags to CORRUPTED before claims start, the pool no longer allows survived claims and funds follow the corrupted path instead.

    The code overwrites outcome, and claim functions depend on the current stored outcome.

    Impact 3 — Incorrect attacker/bounty indexing

    In good-faith corrupted cases, the moderator can correct the attacker address before the bounty is claimed. Existing tests show the attacker address can be changed from a wrong address to a right address before claim finality.

    An off-chain system that indexes the first attacker address as final can notify the wrong attacker or misrepresent bounty entitlement.

    Impact 4 — Incorrect risk dashboards / accounting

    Protocols, dashboards, or automated reporting tools that aggregate pool outcomes can count a pool under the wrong final result if they do not process later re-flags or check finality.


Proof of Concept

The existing test suite already contains a direct PoC for this behavior.

PoC 1 — Outcome changes from SURVIVED to CORRUPTED before claim finality

This test proves:

  1. The first flagOutcome() succeeds with SURVIVED.

  2. No claim has started after the first flag.

  3. The moderator can then re-flag to CORRUPTED.

  4. The final on-chain outcome() is now CORRUPTED, not SURVIVED.

PoC 2 — Attacker address changes before claim finality

This test proves:

  1. A good-faith corrupted outcome can first be emitted with one attacker address.

  2. Before the attacker claims, the moderator can re-flag with a different attacker address.

  3. The current stored attacker is the second address, not the first.

PoC 1 — Outcome changes from SURVIVED to CORRUPTED before claim finality
function test_flagOutcome_canReflagBeforeAnyClaim() external {
// Typo recovery: moderator flags SURVIVED but then realises the breach was in-scope.
// Re-flagging to CORRUPTED is allowed while no claim has succeeded.
_stake(alice, 100 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// @> First OutcomeFlagged event emits SURVIVED.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED));
assertFalse(pool.claimsStarted());
// @> Second OutcomeFlagged event emits CORRUPTED before claimsStarted.
// @> Off-chain systems that finalized on the first event are now wrong.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertFalse(pool.goodFaith());
}
PoC 2 — Attacker address changes before claim finality
function test_flagOutcome_canReflagAttackerAddress() external {
// Typo recovery on the attacker field: moderator named a wrong address, re-flags with
// the correct one before the typo'd address claimed.
_stake(alice, 100 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
address wrongAttacker = makeAddr("typo");
address rightAttacker = makeAddr("whitehat");
// @> First OutcomeFlagged event names wrongAttacker.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, wrongAttacker);
assertEq(pool.attacker(), wrongAttacker);
// @> Second OutcomeFlagged event names rightAttacker.
// @> Off-chain systems that finalized on the first attacker value are wrong.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, rightAttacker);
assertEq(pool.attacker(), rightAttacker);
}

Recommended Mitigation

Because the on-chain re-flag behavior is intentional, the goal should not necessarily be to remove re-flagging. The safer mitigation is to make event-level finality explicit.

Option A — Emit a separate finality event when claims start

Emit a dedicated OutcomeFinalized event the first time claimsStarted becomes true.

This should be added anywhere claimsStarted is first flipped to true, including:

  • claimSurvived()

  • claimCorrupted()

  • claimAttackerBounty() when payout succeeds

  • sweepUnclaimedCorrupted()

  • claimExpired() mechanical resolution paths

Relevant current claimsStarted writes exist in multiple paths.


Option B — Include finality metadata in OutcomeFlagged

Add a monotonic outcomeVersion and emit it with every flag.

This helps indexers detect superseded outcomes.


Option C — Rename the event or add NatSpec warning

If storage/event changes are undesirable, add explicit interface-level warning documentation.

This is the lowest-risk mitigation but relies on integrators reading the interface comments.


Medium

This is best classified as Medium integration / UX-security risk.

The on-chain behavior is intentional and documented, so this is not an on-chain accounting or access-control bug. However, the emitted event can easily be consumed as a final settlement signal despite not being final until the claimsStarted latch is set.

Option A — Emit a separate finality event when claims start
diff --git a/src/interfaces/IConfidencePool.sol b/src/interfaces/IConfidencePool.sol
index xxxx..yyyy 100644
--- a/src/interfaces/IConfidencePool.sol
+++ b/src/interfaces/IConfidencePool.sol
@@
event OutcomeFlagged(address indexed moderator, PoolStates.Outcome outcome, bool goodFaith, address attacker);
+ event OutcomeFinalized(PoolStates.Outcome outcome, bool goodFaith, address attacker);
diff --git a/src/ConfidencePool.sol b/src/ConfidencePool.sol
index xxxx..yyyy 100644
--- a/src/ConfidencePool.sol
+++ b/src/ConfidencePool.sol
@@
- if (!claimsStarted) claimsStarted = true;
+ if (!claimsStarted) {
+ claimsStarted = true;
+ emit OutcomeFinalized(outcome, goodFaith, attacker);
+ }
Option B — Include finality metadata in OutcomeFlagged
diff --git a/src/interfaces/IConfidencePool.sol b/src/interfaces/IConfidencePool.sol
index xxxx..yyyy 100644
--- a/src/interfaces/IConfidencePool.sol
+++ b/src/interfaces/IConfidencePool.sol
@@
- event OutcomeFlagged(address indexed moderator, PoolStates.Outcome outcome, bool goodFaith, address attacker);
+ event OutcomeFlagged(
+ address indexed moderator,
+ PoolStates.Outcome outcome,
+ bool goodFaith,
+ address attacker,
+ uint256 outcomeVersion,
+ bool finalized
+ );
diff --git a/src/ConfidencePool.sol b/src/ConfidencePool.sol
index xxxx..yyyy 100644
--- a/src/ConfidencePool.sol
+++ b/src/ConfidencePool.sol
@@
+ uint256 public outcomeVersion;
@@
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
+ ++outcomeVersion;
@@
- emit OutcomeFlagged(msg.sender, newOutcome, goodFaith_, attacker_);
+ emit OutcomeFlagged(msg.sender, newOutcome, goodFaith_, attacker_, outcomeVersion, claimsStarted);
Option C — Rename the event or add NatSpec warning
diff --git a/src/interfaces/IConfidencePool.sol b/src/interfaces/IConfidencePool.sol
index xxxx..yyyy 100644
--- a/src/interfaces/IConfidencePool.sol
+++ b/src/interfaces/IConfidencePool.sol
@@
- /// @dev `moderator` is the caller of `flagOutcome`, or `address(0)` when the outcome was
- /// auto-resolved at expiry via `claimExpired` (mechanical, no human decision-maker).
+ /// @dev WARNING: This event is not necessarily final. The moderator may re-flag before
+ /// `claimsStarted` becomes true. Off-chain consumers must treat the latest event before
+ /// finality as provisional and must not finalize settlement from the first event alone.
+ /// `moderator` is the caller of `flagOutcome`, or `address(0)` when the outcome was
+ /// auto-resolved at expiry via `claimExpired` (mechanical, no human decision-maker).
event OutcomeFlagged(address indexed moderator, PoolStates.Outcome outcome, bool goodFaith, address attacker);

Support

FAQs

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

Give us feedback!