FoundrySolidityLayer 2
7.25 ETH
View results
Submission Details
Impact: low
Likelihood: medium
Invalid

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

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.

Updates

Lead Judging Commences

inallhonesty Lead Judge
about 1 month ago
inallhonesty Lead Judge 22 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

QA / Informational / Gas

inallhonesty Lead Judge 22 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

QA / Informational / Gas

Support

FAQs

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

Give us feedback!