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

sweepUnclaimedBonus() emits value-moving sweep while intentionally leaving outcome re-flaggable, creating complex finality risk for off-chain observers

Author Revealed upon completion

Root + Impact

Description

  • Normal Behavior

    The protocol intentionally allows the moderator to re-flag an outcome before the first genuine claim. The documentation states that flagOutcome() may be re-flagged before the first claim so the moderator can correct a typo or incorrect outcome.


    The core finality condition is claimsStarted, not merely that an outcome was flagged. flagOutcome() only blocks re-flagging when an outcome is already set and claimsStarted is true.


    sweepUnclaimedBonus() is intentionally excluded from this finality latch. The code comment explains that it does not set claimsStarted because a trivial direct-transfer donation could otherwise let anyone lock the moderator’s re-flag window.

  • Specific Issue

    sweepUnclaimedBonus() can move tokens to recoveryAddress while leaving claimsStarted == false. Because claimsStarted remains false, the moderator can still re-flag the outcome afterward.


    This is not a direct on-chain exploit by itself. The issue is the complex state it creates:

    1. OutcomeFlagged says one outcome.

    2. BonusSwept moves value under that apparent outcome.

    3. claimsStarted remains false.

    4. The moderator can still re-flag to another outcome.

    5. Off-chain systems that assumed the sweep made the outcome final are wrong.

    The project already has a regression test proving this exact behavior.

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
...
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
...
// @> Intentionally does NOT set claimsStarted.
// @> Therefore this value-moving sweep does not finalize the outcome.
// @> The moderator can still re-flag afterward.
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}

sweepUnclaimedBonus() is allowed only for SURVIVED or EXPIRED.

It computes reserved principal/bonus and sweeps excess balance.

It intentionally does not set claimsStarted.

It transfers tokens and emits BonusSwept.

The re-flag gate in flagOutcome() only blocks changes when claimsStarted is true.

Risk

Likelihood:

  • Likelihood: Medium

  • Reason 1 — The behavior is intentionally reachable

    The test suite includes a regression test proving that a 1 wei donation can make sweepUnclaimedBonus() succeed after SURVIVED is flagged, while claimsStarted remains false.

  • Reason 2 — Re-flagging after the sweep remains possible

    After sweepUnclaimedBonus(), the same test re-flags from SURVIVED to CORRUPTED, proving that the outcome remains mutable after the sweep.

  • Reason 3 — Off-chain systems commonly treat value-moving events as finality signals

    A BonusSwept event can look like an end-of-life pool action. However, in this protocol, it is explicitly not finality. Finality comes from claim paths, not this sweep.


Impact:

  • Impact: Medium

  • Impact 1 — Incorrect finalized outcome in indexers/frontends

    A frontend or indexer may display the pool as finalized after OutcomeFlagged(SURVIVED) plus BonusSwept, but the pool can still be re-flagged to CORRUPTED.


    The PoC test demonstrates exactly this transition


  • Impact 2 — Incorrect accounting assumptions

    An accounting system may treat the swept bonus as final surplus distribution under SURVIVED/EXPIRED. Later re-flagging can change the pool’s semantic outcome, even though a sweep already happened.


    sweepUnclaimedBonus() can transfer excess funds while leaving claimsStarted false.


  • Impact 3 — Misleading monitoring / alerting

    Automated bots may interpret BonusSwept as evidence that the moderator re-flag window is closed. In reality, flagOutcome() can still overwrite the outcome until a genuine claim sets claimsStarted.

Proof of Concept

The existing codebase already contains a direct PoC:

PoC source:

PoC Explanation

  1. Alice stakes into the pool.

  2. The registry passes through active risk and then reaches CORRUPTED.

  3. Moderator flags SURVIVED.

  4. A 1 wei donation is sent directly to the pool to create sweepable excess.

  5. sweepUnclaimedBonus() succeeds.

  6. claimsStarted remains false.

  7. Moderator re-flags to CORRUPTED.

  8. The final on-chain outcome is CORRUPTED.

This proves the complex finality surface: a sweep can happen while the outcome is still mutable.

function test_sweepUnclaimedBonus_doesNotLockReflag() external {
// Regression: a 1-wei donation post-flagOutcome must NOT let an attacker lock the
// moderator's re-flag window via sweepUnclaimedBonus.
_stake(alice, 100 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// @> Moderator first flags SURVIVED.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// @> A trivial donation makes sweepUnclaimedBonus succeed.
token.mint(address(pool), 1);
pool.sweepUnclaimedBonus();
// @> Sweep did not finalize the outcome.
assertFalse(pool.claimsStarted(), "sweep must not flip claimsStarted");
// @> Moderator can still re-flag after BonusSwept.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// @> Final stored outcome is now CORRUPTED, not SURVIVED.
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
}

Recommended Mitigation

Because the behavior is intentional, the mitigation should focus on making finality explicit for off-chain consumers rather than blindly setting claimsStarted inside sweepUnclaimedBonus().

Option A — Emit explicit non-final sweep metadata

Add a field to the sweep event or emit a new event indicating that the sweep does not finalize the outcome.

Since sweepUnclaimedBonus() intentionally does not set claimsStarted, this would emit outcomeFinalized = false.


Option B — Emit a separate OutcomeFinalized event when finality actually occurs

Add a dedicated finality event for the first time claimsStarted flips to true.

Apply this pattern to all genuine finality paths where claimsStarted is currently set:

  • claimSurvived()

  • claimCorrupted()

  • claimAttackerBounty() when payout succeeds

  • sweepUnclaimedCorrupted()

  • mechanical claimExpired() resolution branches

This avoids changing the intended behavior of sweepUnclaimedBonus() while giving off-chain systems a safe finality signal.


Option C — Add explicit NatSpec warnings

If event schema changes are not desired, document this explicitly on BonusSwept and sweepUnclaimedBonus().

  • This is the lowest-risk mitigation but depends on integrators reading documentation.

Option A — Emit explicit non-final sweep metadata
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 BonusSwept(address indexed caller, address indexed recoveryAddress, uint256 amount);
+ event BonusSwept(
+ address indexed caller,
+ address indexed recoveryAddress,
+ uint256 amount,
+ bool outcomeFinalized
+ );
diff --git a/src/ConfidencePool.sol b/src/ConfidencePool.sol
index xxxx..yyyy 100644
--- a/src/ConfidencePool.sol
+++ b/src/ConfidencePool.sol
@@
- emit BonusSwept(msg.sender, recoveryAddress, amount);
+ emit BonusSwept(msg.sender, recoveryAddress, amount, claimsStarted);
Since sweepUnclaimedBonus() intentionally does not set claimsStarted, this would emit outcomeFinalized = false.
Option B — Emit a separate OutcomeFinalized event when finality actually occurs
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 C — Add explicit NatSpec warnings
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 BonusSwept(address indexed caller, address indexed recoveryAddress, uint256 amount);
+ /// @dev WARNING: This event does not mean the outcome is final.
+ /// `sweepUnclaimedBonus()` intentionally does not set `claimsStarted`.
+ /// Off-chain consumers must continue tracking `OutcomeFlagged` and finality.
+ event BonusSwept(address indexed caller, address indexed recoveryAddress, uint256 amount);

Support

FAQs

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

Give us feedback!