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

claimsStarted edge cases can leave or close the moderator re-flag window unexpectedly

Author Revealed upon completion

Root + Impact

Description

The single claimsStarted flag represents both economic reliance and mechanical resolution finality, but different entrypoints latch it under different conditions. A value-moving bonus sweep leaves re-flagging open, a zero-value expiry resolver closes it, and zero-payout bounty calls leave it open, making correction finality depend on which permissionless transaction executes first.

The moderator may correct an already-flagged outcome until claimsStarted becomes true. The stated rationale is to allow typo correction before anyone relies on the old distribution, then make the first value movement final.

The implementation follows that rule for ordinary claims and nonzero bounty payments, but not uniformly. sweepUnclaimedBonus() can transfer tracked bonus and reduce totalBonus without setting the latch. Conversely, the first claimExpired() sets it during mechanical resolution even when the caller owns no stake and receives nothing. These choices may each be defensible in isolation, but one boolean no longer communicates or enforces a consistent finality invariant.

src/ConfidencePool.sol
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.
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet(); // @> Every edge case controls this gate.
IAttackRegistry.ContractState state = _observePoolState();
// ... validation and snapshot logic omitted ...
emit OutcomeFlagged(msg.sender, newOutcome, goodFaith_, attacker_);
}
function claimSurvived() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED) revert OutcomeNotSet();
// ... claim accounting omitted ...
if (!claimsStarted) claimsStarted = true; // @> First ordinary value-moving claim closes correction.
stakeToken.safeTransfer(msg.sender, payout);
emit ClaimSurvived(msg.sender, userEligible, bonusShare);
}
function claimAttackerBounty() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
// ... authorization and accounting omitted ...
if (payout > 0) {
corruptedReserve -= payout;
if (!claimsStarted) claimsStarted = true; // @> A successful zero-payout call leaves correction open.
stakeToken.safeTransfer(attacker, payout);
}
emit AttackerBountyClaimed(attacker, payout, newBountyClaimed, bountyEntitlement);
}
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
// ... reserve and amount calculation omitted ...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// Intentionally does NOT set claimsStarted. A direct-transfer donation of as little as 1
// wei would otherwise let anyone flip the flag post-flagOutcome and block the moderator's
// documented pre-claim re-flag window. Genuine reliance only comes from claim entrypoints.
stakeToken.safeTransfer(recoveryAddress, amount); // @> Tracked bonus can move while correction remains open.
emit BonusSwept(msg.sender, recoveryAddress, amount);
}
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
// ... mechanical resolution omitted ...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
// ... snapshot and outcome selection omitted ...
claimsStarted = true; // @> Resolution closes correction before knowing whether caller gets a payout.
}
uint256 userEligible = eligibleStake[msg.sender];
if (userEligible == 0) {
return; // @> A zero-stake resolver moved no value but already latched finality.
}
// ... payout logic omitted ...
}

As a result, transaction ordering can decide whether the moderator can correct the outcome. A beneficiary can close the window with a real claim before a pending correction; a permissionless bonus sweep can move tracked value yet still allow a later outcome rewrite; and any zero-stake expiry caller can finalize the outcome without economic reliance. The current behavior is partly documented as intentional, so this is a policy/finality inconsistency rather than an access-control bypass.

Risk

Likelihood:

  • The issue matters when an initial outcome needs correction and a claim, sweep, bounty call, or expiry resolution is executed before the moderator's corrective transaction.

  • Exploitation is timing-dependent and usually requires observing a pending correction or a delayed moderator, while the registry state must also permit the desired replacement outcome.

Impact:

  • Closing the window first can permanently preserve an erroneous outcome and its complete stake/bonus distribution.

  • Leaving the window open after tracked value moves lets a later re-flag snapshot reduced accounting, underfund a corrected bounty, or otherwise produce a distribution inconsistent with pre-sweep expectations.

Proof of Concept

Create test/audit/CP034ClaimsStartedFinalityEdges.t.sol with the following contents:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CP034ClaimsStartedFinalityEdgesTest is BaseConfidencePoolTest {
function test_BonusSweepMovesTrackedValueButLeavesReflagWindowOpen() external {
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE);
assertEq(pool.totalBonus(), 0);
assertFalse(pool.claimsStarted(), "tracked bonus moved without closing correction");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
}
function test_ZeroStakeClaimExpiredClosesReflagWindowWithoutPayout() external {
_stake(alice, 100 * ONE);
vm.warp(pool.expiry());
uint256 daveBefore = token.balanceOf(dave);
vm.prank(dave);
pool.claimExpired();
assertEq(token.balanceOf(dave), daveBefore, "resolver receives no payout");
assertTrue(pool.claimsStarted(), "mechanical resolution closes correction without value movement");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
}
function test_ZeroPayoutBountyLeavesReflagWindowOpen() external {
_stake(alice, 100 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
deal(address(token), address(pool), 0);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(pool.bountyClaimed(), 0);
assertFalse(pool.claimsStarted(), "zero payout does not latch finality");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED));
}
function test_FirstValueMovingClaimClosesReflagWindow() external {
_stake(alice, 100 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
pool.claimSurvived();
assertTrue(pool.claimsStarted());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP034ClaimsStartedFinalityEdges.t.sol -vv.

  2. Confirm that all four tests pass and demonstrate the open/closed re-flag states after tracked bonus movement, zero-value mechanical resolution, zero-payout bounty execution, and an ordinary value-moving claim.

Recommended Mitigation

Separate mechanical resolution finality from economic-reliance finality. Latch economic action only when protocol-accounted value moves, so a direct 1-wei donation cannot grief correction, but sweeping tracked bonus does close the window.

src/ConfidencePool.sol
-bool public claimsStarted;
+bool public mechanicalResolutionFinal;
+bool public economicActionStarted;
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
- if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
+ if (
+ outcome != PoolStates.Outcome.UNRESOLVED
+ && (mechanicalResolutionFinal || economicActionStarted)
+ ) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
// ... validation and snapshot logic omitted ...
emit OutcomeFlagged(msg.sender, newOutcome, goodFaith_, attacker_);
}
function sweepUnclaimedBonus() external nonReentrant {
// ... reserve and amount calculation omitted ...
+ uint256 trackedBonusSwept;
if (totalEligibleStake == 0 || riskWindowStart == 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ trackedBonusSwept = amount <= totalBonus ? amount : totalBonus;
+ totalBonus -= trackedBonusSwept;
}
+ if (trackedBonusSwept != 0) economicActionStarted = true;
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
// ... mechanical resolution omitted ...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
// ... snapshot and outcome selection omitted ...
- claimsStarted = true;
+ mechanicalResolutionFinal = true;
}
// ... optional caller payout omitted ...
}

Update ordinary claims, corrupted sweeps, and nonzero bounty transfers to set economicActionStarted; leave genuine zero-payout calls unlatching. If the intended policy is instead “no correction after any successful post-resolution call,” encode that rule explicitly and accept the 1-wei donation grief tradeoff.

Support

FAQs

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

Give us feedback!