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

An unclaimed position can indefinitely lock global bonus-rounding residue

Author Revealed upon completion

Root + Impact

sweepUnclaimedBonus() reserves the entire unpaid bonus snapshot whenever any principal remains unclaimed. Individual bonus claims, however, pay floor-rounded shares. The difference is global rounding residue that no position can receive. A holder can leave a zero-share position unclaimed and indefinitely prevent recoveryAddress from receiving that residue.

Description

Each SURVIVED/EXPIRED claim pays floor(score[i] × B / S) and increments claimedBonus by that amount. The sweep instead reserves the aggregate unpaid snapshot:

if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();

For the remaining positions U, the true outstanding bonus liability is:

sum(floor(score[i] × B / S), i in U).

The contract reserves B - claimedBonus instead. The difference is the fixed global residue:

B - sum(floor(score[i] × B / S), all positions).

That residue is not payable to any staker, yet it remains reserved until every principal is claimed.

Risk

Likelihood

  • The factory permits ordinary ERC-20s without a decimals restriction.

  • Equal-score positions and a bonus not divisible by their count deterministically create residue.

  • Any holder can maintain the lock simply by not claiming; there is no deadline.

Impact

  • A third party's unallocatable bonus cannot reach recoveryAddress while a zero-share principal remains.

  • The holdout cannot capture the residue; it receives only its principal.

  • The same accounting is used by both SURVIVED and EXPIRED.

Proof of Concept

No attachment or private fixture is required. Configure one valid pool with minStake = 1, a standard two-decimal ERC-20, and a test account holding exactly 201 raw units. The account funds 101 equal one-unit stakes and a 100-unit bonus. All stakes occur at the same timestamp before the risk window opens, so their k=2 scores are equal.

function test_roundingResidueStaysLockedBehindZeroShareHoldout() public {
address recovery = pool.recoveryAddress();
address holdout = address(uint160(0x2000 + 100));
// 101 × 1-unit principals plus a 100-unit sponsor bonus = 201 units.
for (uint256 i; i < 101; ++i) {
address user = address(uint160(0x2000 + i));
token.transfer(user, 1);
vm.startPrank(user);
token.approve(address(pool), 1);
pool.stake(1);
vm.stopPrank();
}
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
token.approve(address(pool), 100);
pool.contributeBonus(100);
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Each entitlement is floor(100 / 101) = 0. Claim 100 positions.
for (uint256 i; i < 100; ++i) {
vm.prank(address(uint160(0x2000 + i)));
pool.claimSurvived();
}
assertEq(pool.eligibleStake(holdout), 1);
assertEq(pool.totalEligibleStake(), 1);
assertEq(pool.claimedBonus(), 0);
assertEq(token.balanceOf(address(pool)), 101); // 1 principal + 100 residue
assertEq(token.balanceOf(recovery), 0);
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
// The holdout receives only its principal. Recovery becomes possible only now.
vm.prank(holdout);
pool.claimSurvived();
assertEq(token.balanceOf(holdout), 1);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 100);
}

The final two assertions prove the 100-unit balance was never an outstanding entitlement: it becomes sweepable solely because the unrelated one-unit principal was claimed. Replacing SURVIVED with the EXPIRED resolution path produces the same lock because both paths use the same share and sweep accounting.

Recommended Mitigation

Permit a zero-bonus position to be settled permissionlessly once a genuine claim has made the outcome immutable. The position's principal is sent to its owner; a nonzero bonus entitlement remains protected and cannot be forced. This avoids adding a staker list or attempting to infer rounding residue from aggregate accounting.

function settleZeroBonusPosition(address staker) external nonReentrant {
if (!claimsStarted) revert ClaimsNotStarted();
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
if (hasClaimed[staker]) revert AlreadyClaimed();
uint256 principal = eligibleStake[staker];
if (principal == 0) revert NoUnsettledPosition();
_clampUserSums(staker);
if (_bonusShare(staker, principal) != 0) revert NonzeroBonusEntitlement();
hasClaimed[staker] = true;
eligibleStake[staker] = 0;
totalEligibleStake -= principal;
stakeToken.safeTransfer(staker, principal);
}

The claimsStarted guard preserves the moderator's pre-claim re-flag window. The nonzero-entitlement guard preserves every real bonus claim. Once the last zero-share holdout is settled, totalEligibleStake becomes zero and the existing sweep transfers only the already-unallocatable residue to recoveryAddress.

Support

FAQs

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

Give us feedback!