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

`sweepUnclaimedBonus` intentionally skips `claimsStarted`, enabling re-flag snapshot desync

Author Revealed upon completion

Root + Impact

Value leaves the contract without the finality latch being set, creating a window where the moderator can re-flag against a deflated snapshot. When combined with a CORRUPTED re-flag, this permanently reduces the whitehat's bounty entitlement.

Description

  • claimsStarted is a one-way latch (I-6) that locks the moderator's re-flag window once any post-resolution value movement occurs. Every claim function sets it. This ensures the moderator cannot re-flag after stakers have relied on the current outcome to claim funds.

  • sweepUnclaimedBonus is the ONLY value-moving function that does NOT set claimsStarted (line 503-505 comment: "Intentionally does NOT set claimsStarted"). The rationale is to prevent a 1-wei donation from blocking the moderator's re-flag window. However, this means the moderator can re-flag after a sweep. When riskWindowStart == 0, the entire bonus sweeps and totalBonus is decremented — the re-flag then captures deflated state. Totals are conserved (no double-spend), but when combined with a re-flag to CORRUPTED good-faith, the whitehat's bountyEntitlement is permanently reduced (see M-2).

// ConfidencePool.sol:sweepUnclaimedBonus — lines 503-506
@> // 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.
stakeToken.safeTransfer(recoveryAddress, amount);

Risk

Likelihood:

Reason 1 — When `riskWindowStart == 0` at resolution, the entire bonus is unreserved and sweepable. This is the default state on the SURVIVED happy path (see M-1).
Reason 2 — The sweep is permissionless. Any third party can trigger it, and the moderator may re-flag without being aware that the sweep already occurred.

Impact:

Impact 1 — Combined with a moderator re-flag to CORRUPTED good-faith, the whitehat's `bountyEntitlement` is permanently reduced (see M-2 for the full attack chain).
Impact 2 — Off-chain accounting systems that rely on `claimsStarted` as the finality signal will incorrectly believe the re-flag window is open when value has already left the contract.

Proof of Concept

function test_L5_SweepSkipsClaimsStarted() public {
_stake(alice, 100 * ONE);
_contributeBonus(bob, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertFalse(pool.claimsStarted());
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertGt(token.balanceOf(recovery) - recoveryBefore, 0); // bonus moved
assertFalse(pool.claimsStarted()); // re-flag window still open
}

Forge output:

[PASS] test_L5_SweepSkipsClaimsStarted()
sweepUnclaimedBonus moved 50000000000000000000 without setting claimsStarted
Moderator re-flag window remains open after sweep

Recommended Mitigation

Setting claimsStarted = true in sweepUnclaimedBonus closes the re-flag window whenever value leaves the contract, preventing the moderator from re-snapshotting against a deflated totalBonus. This is the simplest fix but changes the documented design trade-off around 1-wei donation griefing.

function sweepUnclaimedBonus() external nonReentrant {
+ claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, amount);
}

Alternatively, document that the moderator must verify no sweep occurred before re-flagging.

Support

FAQs

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

Give us feedback!