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

An unclaimed stake can permanently block sweeping unclaimable bonus rounding dust

Author Revealed upon completion

An unclaimed stake can permanently block sweeping unclaimable bonus rounding dust

Root + Impact

Bonus is rounded down independently for each staker, so the resulting rounding dust belongs to no user. As long as one stake remains unclaimed, sweepUnclaimedBonus() reserves all unpaid bonus, including dust that no user can claim. With no claimFor function or claim deadline, the final staker can indefinitely prevent that dust from being swept and prevent the Pool from completing balance cleanup.

This is Low severity. Only rounding dust in one Pool is frozen; other stakers can claim, state transitions continue, and other Pools are unaffected. The attacker must retain at least one minStake position until settlement. For common 6- or 18-decimal tokens, dust is normally smaller than the cost of the attack, but low-decimal tokens or a low minStake can make the griefing economically meaningful.

Description

Normally, claimSurvived() and claimExpired() pay each user principal and time-weighted bonus, and sweepUnclaimedBonus() subsequently transfers the balance that no longer belongs to any user to recoveryAddress.

_bonusShare() uses Math.mulDiv independently for each user, so every division rounds down. The sum of all payouts may be less than snapshotTotalBonus. While one user remains unclaimed, sweep treats all remaining bonus as that user's potential entitlement and fails to remove dust that is already known to be unclaimable.

// src/ConfidencePool.sol
function _bonusShare(address user) internal view returns (uint256) {
// @> Each individual share is rounded down.
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}
function sweepUnclaimedBonus() external nonReentrant {
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
// @> Includes both actual unpaid entitlements and unclaimable rounding dust.
reserved += snapshotTotalBonus - claimedBonus;
}
}
uint256 amount = balance - reserved;
if (amount == 0) revert NothingToSweep();
}

For example, Alice and Bob have equal weight and snapshotTotalBonus = 51. Each can claim only floor(51 / 2) = 25, leaving 1 unit of dust. After Alice claims, claimedBonus = 25. If Bob never claims, the function reserves 51 - 25 = 26 bonus units even though Bob can claim only 25. The extra unit has no claimant. Under normal balances, balance == reserved, so sweep reverts with NothingToSweep forever because no timeout exists.

When all users claim, totalEligibleStake == 0 and this reserve branch is not executed, allowing dust to be swept. The problem is not that dust is always unrecoverable; it is that a permanently unclaimed position binds dust to an otherwise legitimate unclaimed entitlement.

Risk

Likelihood:

  • The Pool resolves as SURVIVED or EXPIRED, observed a risk window, and creates nonzero bonus rounding dust.

  • At least one account with eligibleStake continually does not claim after settlement. An attacker can claim from multiple addresses and leave one minStake position behind.

  • Low token decimals or a small minStake make accumulated dust potentially larger than the retained position and multi-address operating costs.

Impact:

  • Bonus dust that belongs to no staker remains permanently in this Pool and recoveryAddress cannot complete expected balance recovery.

  • Other stakers can claim independently; the issue does not cause protocol-wide denial of service and does not affect CORRUPTED settlement.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @notice PoC for L-02: an unclaimed stake makes unclaimable bonus dust part of the sweep reserve.
contract L02UnclaimedStakerBlocksDustSweepPoC is BaseConfidencePoolTest {
function testPoC_LastUnclaimedStakerKeepsRoundingDustUnsweepable() external {
uint256 stakeAmount = 100 * ONE;
uint256 bonus = 51;
_stake(alice, stakeAmount);
_stake(bob, stakeAmount);
_contributeBonus(carol, bonus);
// Both deposits are clamped to the same risk start, so their 51-unit bonus is split as
// floor(51 / 2) + floor(51 / 2) = 25 + 25, leaving one unit with no claimant.
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceBonus = token.balanceOf(alice) - aliceBefore - stakeAmount;
assertEq(aliceBonus, 25, "first rounded bonus share");
assertEq(pool.claimedBonus(), 25, "only Alice's rounded share was paid");
assertEq(pool.totalEligibleStake(), stakeAmount, "Bob remains the final unclaimed staker");
uint256 remainingBonusReserve = pool.snapshotTotalBonus() - pool.claimedBonus();
uint256 actualBobBonus = 25;
uint256 dust = remainingBonusReserve - actualBobBonus;
assertEq(dust, 1, "reserve contains one unclaimable dust unit");
emit log_named_uint("snapshot bonus", pool.snapshotTotalBonus());
emit log_named_uint("bonus paid to Alice", pool.claimedBonus());
emit log_named_uint("bonus reserve while Bob is unclaimed", remainingBonusReserve);
emit log_named_uint("Bob's actual claimable bonus", actualBobBonus);
emit log_named_uint("unclaimable rounding dust blocked by reserve", dust);
// The reserve is principal (100e18) + 26 bonus, exactly equal to the Pool balance.
// sweepUnclaimedBonus cannot distinguish Bob's 25 entitlement from the one-unit dust.
assertEq(token.balanceOf(address(pool)), stakeAmount + remainingBonusReserve, "dust is trapped in reserve");
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
}
}
[PASS] testPoC_LastUnclaimedStakerKeepsRoundingDustUnsweepable() (gas: 649840)
Logs:
snapshot bonus: 51
bonus paid to Alice: 25
bonus reserve while Bob is unclaimed: 26
Bob's actual claimable bonus: 25
unclaimable rounding dust blocked by reserve: 1
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.45ms (579.54µs CPU time)

Recommended Mitigation

Add a permissionless claimFor(user) that always pays user, allowing any caller to settle the final position without taking its entitlement. Once all stake is cleared, the existing sweep path can recover dust.

+function claimFor(address user) external nonReentrant {
+ _claim(user); // Reuse claim logic; payout recipient must remain user.
+}
function sweepUnclaimedBonus() external nonReentrant {
// Existing sweep can recover dust after totalEligibleStake reaches zero.
}

Alternatively, define a claim deadline after which unclaimed funds may be swept. That changes users' rights to principal and bonus and must be disclosed as a protocol rule. Raising minStake only raises the attack cost; it does not break the mechanism binding dust to the final unclaimed stake.

Support

FAQs

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

Give us feedback!