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

Oversized standard ERC20 stake can brick risk-window observation and claims

Author Revealed upon completion

Summary

ConfidencePool stores stake-time accounting as amount * timestamp and amount * timestamp * timestamp.
There is no maximum stake amount or token supply constraint. A staker can deposit an extremely large,
but still valid standard ERC20 amount that does not overflow at deposit time, then later causes either:

  • _markRiskWindowStart() to overflow when the active-risk window is first observed; or

  • _bonusShare() to overflow during post-resolution claims after the risk window was successfully opened.

This makes active-risk observation revert in pokeRiskWindow(), stake(), contributeBonus(), withdraw(),
and claimExpired() while the registry is in UNDER_ATTACK or PROMOTION_REQUESTED. In the claim-time
variant, the pool can resolve as SURVIVED, but honest stakers cannot claim because claimSurvived() reverts.

Affected Code

  • src/ConfidencePool.sol:252-260

    • stake() records received * newEntry * newEntry.

  • src/ConfidencePool.sol:800-815

    • _markRiskWindowStart() later recomputes global sums as totalEligibleStake * t * t.

  • src/ConfidencePool.sol:695-718

    • _bonusShare() recomputes user/global scores as T * T * stake + stakeTimeSq.

  • src/ConfidencePool.sol:783-797

    • _observePoolState() calls _markRiskWindowStart() on first active-risk observation.

Root Cause

The amount can be safe for the entry timestamp but unsafe for a later risk-window timestamp.
For current-scale timestamps, the safe maximum is approximately:

maxAmount(t) = type(uint256).max / (t * t)

Because maxAmount(t) decreases as t increases, an attacker can choose amounts that are safe for
the earlier timestamp but unsafe for a later risk-start or outcome timestamp:

type(uint256).max / (t_risk * t_risk) < amount <= type(uint256).max / (t_entry * t_entry)

At the repository test base timestamp 1_750_000_000, waiting 31 days leaves a valid attack interval of about:

115471291757826553667087349033603792343994223329696154405 token units

So the interval is non-empty.

Proof Of Concept

This test was added locally as:

test/unit/ConfidencePool.overflow.t.sol
function testOversizedStakeBricksRiskWindowStart() external {
uint256 tEntry = block.timestamp;
uint256 tRisk = tEntry + 31 days;
uint256 maxAtEntry = type(uint256).max / (tEntry * tEntry);
uint256 amount = type(uint256).max / (tRisk * tRisk) + 1;
assertLe(amount, maxAtEntry, "amount must be safe at entry time");
_stake(alice, amount);
vm.warp(tRisk);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
}

Expected result: stake() succeeds, but the first active-risk observation reverts on arithmetic overflow in
_markRiskWindowStart() when calculating sumStakeTimeSq = totalEligibleStake * t * t.

The same test file also includes a claim-time variant:

function testOversizedStakeBricksSurvivedClaimAfterRiskWindowStarts() external {
uint256 tStart = block.timestamp;
uint256 tEnd = tStart + 30 days;
uint256 maxAtStart = type(uint256).max / (tStart * tStart);
uint256 amount = type(uint256).max / (tEnd * tEnd) + 1;
assertLe(amount, maxAtStart, "amount must be safe when risk starts");
_stake(alice, amount);
_contributeBonus(carol, ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(tEnd);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
}

Expected result: risk-window start and outcome flagging both succeed, but claimSurvived() reverts when
_bonusShare() calculates T * T * userEligible.

Local verification:

forge test --match-contract ConfidencePoolOverflowTest -vvv

Result:

Ran 2 tests for test/unit/ConfidencePool.overflow.t.sol:ConfidencePoolOverflowTest
[PASS] testOversizedStakeBricksRiskWindowStart() (gas: 318859)
[PASS] testOversizedStakeBricksSurvivedClaimAfterRiskWindowStarts() (gas: 527957)
2 tests passed; 0 failed; 0 skipped

Regression check:

forge test --match-path 'test/unit/*'

Result:

253 tests passed; 0 failed; 0 skipped

Impact

While the registry remains in an active-risk state, the pool cannot seal riskWindowStart.
Entrypoints that observe pool state during active risk revert, including pokeRiskWindow(), withdraw(),
stake(), contributeBonus(), and claimExpired() after expiry.

If the active-risk window is opened before the amount becomes unsafe, the pool can still later resolve as
SURVIVED, but claimSurvived() reverts for claimants once _bonusShare() evaluates the larger
outcome timestamp. This blocks distribution of principal and bonus through the normal survivor claim path.

If the registry later reaches a terminal state without a successful active-risk observation, riskWindowStart
stays zero. Under the protocol's documented no-observed-risk rule, stakers recover principal but receive no
bonus; the bonus can be swept to recoveryAddress. This can grief honest stakers' expected bonus distribution.

Severity Notes

This depends on a standard ERC20 with a very large supply or denomination being allowlisted by the factory owner.
It does not require a fee-on-transfer, rebasing, callback, or otherwise non-standard token. Because the factory
does not constrain token supply or maximum stake size, the issue is best framed as a low-severity griefing/DoS
finding unless contest rules treat extreme token supplies as out of scope.

Recommended Fix

Add an upper bound for stake amounts based on the maximum possible expiry timestamp, or compute score terms with
checked Math.mulDiv/bounded arithmetic and reject stake amounts that would make future global score updates overflow.

For example, at stake time:

uint256 maxSafeStake = type(uint256).max / (uint256(expiry) * uint256(expiry));
if (totalEligibleStake + received > maxSafeStake) revert StakeTooLarge();

Using expiry is conservative because _markRiskWindowStart() and _markRiskWindowEnd() cap timestamps at expiry.

Support

FAQs

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

Give us feedback!