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

Large stakes can overflow bonus-score calculations and permanently freeze the entire pool

Author Revealed upon completion

Root + Impact

Description

The pool is intended to distribute the bonus according to each staker's quadratic time-weighted score:

score = amount × (T - entryTime)²

After the pool resolves as SURVIVED or EXPIRED, every eligible staker should be able to recover their principal plus their calculated share of the bonus.

However, the contract expands the quadratic formula using several checked uint256 multiplications involving absolute Unix timestamps. The protocol does not impose an upper bound on the token's decimals, raw token supply, individual stake size, or aggregate stake size.

A sufficiently large but valid ERC-20 deposit can therefore be accepted during stake(), while later causing _bonusShare() to revert from arithmetic overflow when a larger timestamp T is used at claim time.

The deposit accounting stores the following timestamp moments:

uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
uint256 contribTime = received * newEntry;
// @> A sufficiently large `received` amount may fit here at the deposit
// @> timestamp but fail when multiplied by the later resolution timestamp.
uint256 contribTimeSq = received * newEntry * newEntry;
eligibleStake[msg.sender] += received;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;

When a staker claims, _bonusShare() evaluates the expanded polynomial using the later upper-bound timestamp T:

uint256 T = outcomeFlaggedAt;
uint256 u = uint256(uint160(staker));
uint256 userTwice = 2 * T * userSumStakeTime[staker];
// @> Checked multiplication can overflow before Math.mulDiv() is reached.
uint256 userPlus =
T * T * userEligible + userSumStakeTimeSq[staker];
uint256 userScore = userPlus - userTwice;
uint256 twice = 2 * T * snapshotSumStakeTime;
// @> This global expression can also overflow and brick every claimant.
uint256 plus =
T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 globalScore = plus - twice;

Math.mulDiv() cannot protect these calculations because the overflow occurs before mulDiv() is called.

An attacker can select a stake amount A such that:

A × S² <= type(uint256).max
A × T² > type(uint256).max

where:

  • S is the earlier entry or risk-window timestamp;

  • T is the later resolution timestamp;

  • T > S.

Consequently, the attacker's deposit and the opening of the risk window can succeed, but all subsequent bonus-share calculations revert.

Once the risk window has opened, withdraw() is permanently disabled. After resolution, the pool also cannot return to the unresolved state. Therefore, affected stakers have no alternative route through which to recover their principal.

The repository explicitly supports standard ERC-20 tokens but does not restrict token decimals or maximum raw balances. A high-decimal ERC-20 can make the required raw amount economically small while still triggering the overflow. fileciteturn3file0L1-L1

Risk

An attacker can permanently deny all SURVIVED or EXPIRED claims in the affected pool.

The consequences are:

  • honest stakers cannot recover their principal;

  • honest stakers cannot recover their bonus entitlement;

  • the attacker's own stake is also locked, but the economic cost can be small when using a high-decimal token;

  • withdrawals cannot be reopened after the risk window starts;

  • the resolved outcome cannot be reset after claims begin;

  • the pool balance may remain permanently inaccessible.

Because this can freeze all principal and bonus held by a pool, the impact is High.

Likelihood

The attack requires the pool factory owner to allowlist an ERC-20 whose raw balances can reach the required magnitude, such as a token with unusually high decimals or supply.

The factory allowlist lowers the likelihood, but the implementation does not enforce any decimal, supply, or maximum-stake restriction itself. A token can also remain ERC-20 compliant while using high decimals.

Therefore, the likelihood is assessed as Medium.

Proof of Concept

Add the following test to a test contract inheriting BaseConfidencePoolTest:

function test_LargeStakePermanentlyFreezesClaims() external {
uint256 T = pool.expiry();
// This amount is just above the largest amount for which
// amount * T * T remains representable.
uint256 maliciousStake = type(uint256).max / (T * T) + 1;
// Honest user deposits principal.
_stake(alice, 100 * ONE);
// Attacker deposits a sufficiently large raw token amount.
_stake(bob, maliciousStake);
// Ensure _bonusShare() does not return early because the bonus is zero.
_contributeBonus(carol, 1);
// Open the risk window before expiry. The earlier timestamp calculations
// still fit for the chosen malicious amount.
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
// Resolve mechanically as EXPIRED.
vm.warp(T);
vm.prank(dave);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.EXPIRED)
);
// The honest user's claim reverts while calculating the global score.
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimExpired();
// The attacker's claim also reverts.
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
pool.claimExpired();
// Principal and bonus remain in the pool.
assertEq(
token.balanceOf(address(pool)),
maliciousStake + 100 * ONE + 1
);
// Withdrawals cannot be used after the risk window has opened.
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
}

The exact amount may need to be selected using the test's risk-window and resolution timestamps so that the earlier timestamp-square calculation succeeds while the later one overflows.

Recommended Mitigation

Avoid evaluating the quadratic score using absolute Unix timestamps.

Store and calculate time relative to riskWindowStart:

entryOffset = entryTime - riskWindowStart
duration = T - riskWindowStart
score = amount × (duration - entryOffset)²

This substantially reduces the timestamp magnitude used in the intermediate calculations.

Additionally, enforce a maximum individual and aggregate stake that guarantees every score expression remains within uint256 for the worst-case timestamp.

For example, before accepting a deposit, calculate a conservative maximum based on expiry:

uint256 t = uint256(expiry);
// Account for both T² × stake and the stored stake × entryTime² term.
uint256 maxTotalStake = type(uint256).max / (2 * t * t);
if (received > maxTotalStake - totalEligibleStake) {
revert StakeTooLarge();
}

The implementation should also use overflow-safe algebra or 512-bit intermediate arithmetic for the complete score computation, rather than applying Math.mulDiv() only after the potentially overflowing polynomial has already been evaluated.

Support

FAQs

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

Give us feedback!