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

The bonus dust-sweep reserves the gross remainder, so a zero-share holdout blocks recovery

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: sweepUnclaimedBonus() is the documented cleanup path that returns any pool balance above the remaining stakers' real entitlement — including k=2 rounding dust — to the recovery address.

  • Specific issue: Its reserve is computed from the gross global remainder snapshotTotalBonus - claimedBonus rather than the sum of the remaining stakers' floor-rounded shares, and each claim only subtracts the floored amount it pays, so the reserve stays inflated by the accumulated rounding residue while any staker is unclaimed. A final staker whose own share rounds to zero can hold the whole residue hostage: the sweep reverts NothingToSweep until that staker claims, even though they can never receive any of the reserved amount. On a low-decimal allowlisted token the residue is whole tokens, not negligible wei.

// src/ConfidencePool.sol
// claimSurvived() / claimExpired():
claimedBonus += bonusShare; // @> only the floored bonus actually paid is booked
// sweepUnclaimedBonus():
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus; // @> reserves the gross remainder, not the remaining floored entitlement
}

Risk

Likelihood:

  • WHEN a pool uses a factory-allowlisted standard low-decimal ERC20 and an equal-score cohort shares a bonus small enough that per-user shares floor down materially (in the extreme, bonus B < staker count N, so every share is zero). The compatibility rules exclude fee-on-transfer and rebasing tokens but set no minimum decimals.

  • WHEN all but one staker claim and the final zero-share staker simply does not claim (there is no bonus-claim deadline), the rounding residue stays reserved and sweepUnclaimedBonus reverts NothingToSweep indefinitely.

Impact:

  • Bonus dust owed to the recovery address is denied, and the documented cleanup path is bricked for an unbounded time controlled by a single non-claimer. The pinned amount is bounded by the number of claimants in raw token units, which on a low-decimal token is whole tokens.

  • No staker principal is stolen or frozen and the attacker gains nothing directly, so this is Low, not Medium/High — but it is a real accounting defect fully inside src/ConfidencePool.sol.

Proof of Concept

Uses a plain standard ERC20 whose only customization is decimals() == 0 (transfer accounting unchanged). Save as test/unit/RoundingReserveOverReservation.t.sol and run forge test --match-contract RoundingReserveOverReservationTest -vv.

// 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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract ZeroDecimalStandardToken is ERC20 {
constructor() ERC20("Zero Decimal Standard Token", "ZDST") {}
function decimals() public pure override returns (uint8) { return 0; }
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
contract RoundingReserveOverReservationTest is Test {
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
uint256 internal constant STAKER_COUNT = 10;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
address internal moderator = makeAddr("outcome-moderator");
address internal recovery = makeAddr("recovery");
ZeroDecimalStandardToken internal token;
MockAttackRegistry internal attackRegistry;
ConfidencePoolFactory internal factory;
ConfidencePool internal pool;
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new ZeroDecimalStandardToken();
attackRegistry = new MockAttackRegistry();
MockSafeHarborRegistry safeHarborRegistry = new MockSafeHarborRegistry();
MockAgreement agreement = new MockAgreement(address(this));
agreement.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreement), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(safeHarborRegistry), address(implementation), moderator)
)
)
)
);
assertEq(token.decimals(), 0);
factory.setStakeTokenAllowed(address(token), true);
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCOUNT;
pool = ConfidencePool(
factory.createPool(address(agreement), address(token), block.timestamp + 31 days, 1, recovery, scope)
);
}
function testFinalNonClaimerPinsRoundingResidueBlockingSweep() external {
address[] memory stakers = new address[](STAKER_COUNT);
for (uint256 i; i < STAKER_COUNT; ++i) {
stakers[i] = address(uint160(100_000 + i));
_stake(stakers[i], 1);
}
token.mint(address(this), 9);
token.approve(address(pool), 9);
pool.contributeBonus(9);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Nine stakers exit; floor(9 / 10) == 0, so none receives any bonus.
for (uint256 i; i < STAKER_COUNT - 1; ++i) {
uint256 before = token.balanceOf(stakers[i]);
vm.prank(stakers[i]);
pool.claimSurvived();
assertEq(token.balanceOf(stakers[i]) - before, 1);
}
address lastStaker = stakers[STAKER_COUNT - 1];
assertEq(pool.snapshotTotalBonus(), 9);
assertEq(pool.claimedBonus(), 0);
assertEq(token.balanceOf(address(pool)), 10);
// Documented dust cleanup cannot run: all 9 bonus units are reserved for a user owed zero.
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
// Only after the final staker claims (receiving zero bonus) does the residue release.
uint256 lastBefore = token.balanceOf(lastStaker);
vm.prank(lastStaker);
pool.claimSurvived();
assertEq(token.balanceOf(lastStaker) - lastBefore, 1, "last staker gets principal only");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 9, "pinned bonus finally sweepable");
assertEq(token.balanceOf(address(pool)), 0);
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
}

Passing result: the sweep reverts NothingToSweep while a final zero-share staker holds out, releasing the 9 pinned units only after that staker claims.

Recommended Mitigation

function sweepUnclaimedBonus() external nonReentrant {
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
- reserved += snapshotTotalBonus - claimedBonus;
+ // Reserve only the remaining stakers' summed floored entitlement, so recoverable
+ // rounding dust is swept immediately instead of being pinned by a zero-share holdout.
+ reserved += _remainingBonusReserveUpperBound();
}
}
// ...
}

Track a running upper bound of the bonus weight still attributable to unclaimed users and reserve floor(snapshotTotalBonus * remainingWeight / snapshotGlobalScore) (and the analogous remaining-stake bound in the amount-weighted fallback). Alternatively, add a bounded bonus-claim deadline after which unclaimed positions keep principal but forfeit bonus, enforced identically by the claim and sweep paths.

Support

FAQs

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

Give us feedback!