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

`minStake` Validated Only as Non-Zero — 1-Wei Dust Stakes Enable Minor Griefing of Scope-Iteration Cost and Bonus-Fallback Manipulation

Author Revealed upon completion

Severity: Low

Description

In ConfidencePool.sol, minStake is validated only as != 0, both at init and per-call:

if (minStake_ == 0) revert InvalidAmount();
...
if (amount < minStake) revert BelowMinStake();

A sponsor may set minStake = 1 wei, allowing economically insignificant dust stakes. Two minor grief vectors open:

  1. Scope-iteration cost inflation: A motivated attacker can stake 1 wei across many accounts, and _scopeAccounts-driven off-chain indexers (and any future scope-validation loop in a contract upgrade) iterate over a larger set. This is bounded — the in-contract scope list is sponsor-controlled, not staker-controlled — but the per-user accounting maps (eligibleStake, userSumStakeTime, userSumStakeTimeSq, hasClaimed) grow unboundedly, inflating storage costs and any future claim-loop gas.

  2. Amount-weighted fallback tilt: When globalScore == 0 (e.g., flag in the same block as the only stake, or risk window first observed at/after expiry), _bonusShare falls back to amount-weighted split: Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked). With one dust account and one large account in the same block, an attacker controlling the dust account can shift the dust portion's share to recoveryAddress via sweepUnclaimedBonus once the dust account is the only remaining staker. Magnitude is bounded by the dust amount.

Contract: ConfidencePool.sol
Functions: initialize(), stake()

Impact

Minor grief — no staker principal stolen, only inflated gas/storage cost on the host chain, plus a marginal shift of dust-bonus share. Realistic cost of attack is gas × N accounts, which usually exceeds the griefable value.

Likelihood: Low. Plausible only if the sponsor sets minStake = 1 wei (sponsor's own choice) and a determined attacker is willing to fund N accounts and N stake transactions.

Proof of Code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @title minStake validated only as non-zero -- 1 wei dust allowed.
contract MinStakeAcceptsOneWei is BaseConfidencePoolTest {
using Clones for address;
function test_minStakeAcceptsOneWei_dustStakeEnablesFallbackTilt() external {
ConfidencePool p = _deployMinStakePool(1); // minStake = 1 wei
assertEq(p.minStake(), 1, "minStake=1 accepted at init");
_stakeRaw(p, alice, 1e18);
_stakeRaw(p, attacker, 1);
_contributeBonusRaw(p, carol, 100 * ONE);
// Collapse globalScore to 0: same-block risk window + production
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
p.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
p.pokeRiskWindow();
vm.prank(moderator);
p.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(p.eligibleStake(attacker), 1, "1-wei dust stake accepted");
vm.prank(attacker);
p.claimSurvived();
}
function _deployMinStakePool(uint256 minStake_) internal returns (ConfidencePool) {
ConfidencePool impl = new ConfidencePool();
ConfidencePool p = ConfidencePool(Clones.clone(address(impl)));
p.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 365 days,
minStake_,
recovery,
address(this),
_defaultScope()
);
return p;
}
function _stakeRaw(ConfidencePool p, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(p), amount);
p.stake(amount);
vm.stopPrank();
}
function _contributeBonusRaw(ConfidencePool p, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(p), amount);
p.contributeBonus(amount);
vm.stopPrank();
}
}

Recommended Mitigation

Impose a sensible minimum floor on minStake at init:

uint256 private constant _MIN_MIN_STAKE = 1e15; // 0.001 of an 18-decimals token
...
if (minStake_ < _MIN_MIN_STAKE) revert InvalidAmount();

Optionally, make the floor factory-configurable per stake-token (to accommodate tokens with non-standard decimals), defaulting to a value that makes the N-account gas attack uneconomical.

Support

FAQs

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

Give us feedback!