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

Partial claims can strand bonus dust forever

Author Revealed upon completion

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Explain the specific issue or problem in one or more sentences

**Description:** when ever a user calls `claimSurvived` and `claimExpired`, the bonus share calculated from `ConfidencePool::_bonusShare` function did some rounding down in division due to solidity maths.
Those remaining will stay in the pool forever, in some valuable assets the dust means a lot.
```solidity
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
// No observable risk → no bonus (see contract natspec).
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
// Underflow guards on both subtractions: globally the sum of squares is nonneg, but
// truncation/rounding pathologies could push individual terms over.
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
if (globalScore == 0) {
// No time elapsed in the risk window for anyone → fallback to amount-weighted.
if (snapshotTotalStaked == 0) return 0;
@> return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}
// mulDiv handles the final multiply-then-divide via 512-bit intermediates, so a very
// large `snapshotTotalBonus` cannot push the numerator over uint256 before division.
@> return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}
```

Risk

Likelihood:

  • it will always happen when ever a staker claimed

  • Reason 2

Impact:

  • the lost is for stakers only and is not much

  • Impact 2

Proof of Concept

**Proof of Concept:**
<details>
<summary>Code</summary>
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {console2} from "forge-std/Test.sol";
contract TempPartialClaimDustStrandTest is BaseConfidencePoolTest {
function testPartialClaimLeavesUnreachableDustReservedForever() external {
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
_contributeBonus(carol, 51);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBalanceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 alicePayout = token.balanceOf(alice) - aliceBalanceBefore;
console2.log("alice rewards:", alicePayout);
uint256 bobBalanceBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobPayout = token.balanceOf(bob) - bobBalanceBefore;
console2.log("bob rewards:", bobPayout);
uint256 poolRemainingBalance = token.balanceOf(address(pool));
console2.log("Pool Remaining Balance:", poolRemainingBalance);
}
}
```
</details>

Recommended Mitigation

**Recommended Mitigation:**
when ever every `staker` claimed, meaning the `totalEligibleStake` is zero then owner can sweep.
```diff
+ function sweepDust() public {
+ if (outcome == PoolStates.Outcome.UNRESOLVED) revert();
+ if (totalEligibleStake != 0) revert();
+ stakeToken.safeTransfer(recoveryAddress, amount);
+ }
```

Support

FAQs

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

Give us feedback!