totalEligibleStake overflows the k=2 quadratic accounting, closing every staker-favorable resolution pathNormal behavior: ConfidencePool tracks a running quadratic accumulator (sumStakeTime, sumStakeTimeSq) alongside totalEligibleStake to compute time-weighted bonus shares. Whenever _observePoolState() first observes the registry entering an active-risk state (UNDER_ATTACK or PROMOTION_REQUESTED), _markRiskWindowStart() seals riskWindowStart and eagerly resets the global accumulators so every currently-eligible staker is treated as entering at the current timestamp t. This is a one-time, one-way operation — every subsequent entrypoint (stake, withdraw, flagOutcome, claimExpired, pokeRiskWindow) depends on _observePoolState() succeeding, since all of them call it before doing anything else.
The issue: neither stake() nor contributeBonus() enforces any upper bound on totalEligibleStake. _markRiskWindowStart() recomputes sumStakeTimeSq as totalEligibleStake * t * t using Solidity 0.8's default checked arithmetic no unchecked block anywhere near it. A stake can be individually safe at the timestamp it was deposited (its own per-deposit term, received * newEntry * newEntry, uses that deposit's own timestamp), yet still cause this global recomputation to overflow later, because _markRiskWindowStart() uses whatever timestamp is current whenever the risk window actually seals — which can be arbitrarily later than any individual deposit's entry time. Once this reverts, riskWindowStart never seals, and since every meaningful entrypoint calls _observePoolState() first, the revert propagates to all of them simultaneously, including withdraw() (it observes state before reaching its own gate check) and claimExpired() (the designated permissionless backstop).
A second, independent overflow site exists in _bonusShare, using the same unbounded pattern against snapshotTotalStaked at claim time rather than window-open time:
Since deposits remain open during UNDER_ATTACK (by design — see _assertDepositsAllowed), totalEligibleStake can keep growing after the window opens without ever tripping site 1, and trip site 2 later at resolution instead. The root cause is the same in both places: no upper bound is enforced anywhere on totalEligibleStake relative to the t² term it gets multiplied against.
Likelihood: Low, and narrower than it may first appear once the numbers below are worked through precisely.
The factory's allowedStakeToken allowlist validates only the token's address — createPool/setStakeTokenAllowed perform no check on decimals() whatsoever. The ERC20 standard does not cap decimals(), and nothing in this codebase rejects a high-decimals token.
The base-unit overflow threshold is fixed by the timestamp at the moment the risk window seals (or at claim time, for site 2) — at a realistic ~2026 timestamp, this works out to roughly 3.8 × 10⁵⁸ base units, regardless of the token's decimals. What decimals does change is how that fixed base-unit threshold translates into a "whole token" count, since whole tokens = base units / 10^decimals:
| Decimals | Whole-token threshold | Plausibility |
|---|---|---|
| 18 (standard) | ~3.8 × 10⁴⁰ | Economically nonsensical; no real token has anywhere close to this supply. |
| 40 | ~3.8 × 10¹⁸ (3.8 quintillion) | Still economically nonsensical on its own — this is not meaningfully more reachable than the 18-decimal case. |
| 50 | ~3.8 × 10⁸ (380 million) | No longer outside the range that real (if unusual) high-decimals tokens use. |
Reaching the threshold requires both an unusual token-allowlisting decision (a governance/sponsor choice, not something an attacker can force) and cumulative deposits from any number of legitimate stakers approaching that base-unit threshold while the registry is still in an active-risk state. It is not attacker-input-dependent in the traditional sense — no single actor needs to act maliciously, but the precondition (a 50+-decimal token being allowlisted at all) is itself uncommon.
Impact: High disruption to protocol availability if the precondition is met; principal becomes unrecoverable through the intended path for every staker in the pool simultaneously.
Every entrypoint that calls _observePoolState() — stake, withdraw, flagOutcome, claimExpired, pokeRiskWindow — reverts identically and simultaneously the instant the registry reaches an active-risk state, for as long as totalEligibleStake remains above threshold.
AttackRegistry.sol has no transition from UNDER_ATTACK directly to PRODUCTION that skips PROMOTION_REQUESTED, and CORRUPTED is reachable directly from UNDER_ATTACK/PROMOTION_REQUESTED via markCorrupted. ConfidencePool.sol classifies both UNDER_ATTACK and PROMOTION_REQUESTED as active-risk states via _isActiveRiskState(), so the identical overflow retriggers on the transition through PROMOTION_REQUESTED. _markRiskWindowEnd() — the function invoked on reaching a terminal state — never touches sumStakeTime/sumStakeTimeSq, so the CORRUPTED path does not retrigger the overflow.
The only resolution path that remains reachable is CORRUPTED — but that path never returns principal to the staker. It either sweeps the entire pool to recoveryAddress (bad-faith) or pays a named attacker (good-faith) via claimCorrupted/claimAttackerBounty. The staker's own funds are, through every reachable path, unrecoverable by them.
Full runnable file: test/poc/TotalEligibleStakeOverflow.t.sol.
Enforce an upper bound on totalEligibleStake at every point it grows, sized so the worst-case t² · totalEligibleStake product can never exceed type(uint256).max, using type(uint32).max as the conservative upper bound on t (the value is always capped at expiry, itself a uint32):
This closes both overflow sites simultaneously, since both _markRiskWindowStart and _bonusShare key off the same totalEligibleStake/snapshotTotalStaked value that this check gates at the source. Using type(uint32).max rather than a realistic present-day timestamp keeps the bound correct for the entire lifetime the contract's own expiry type can represent (up to year ~2106), rather than a value that would itself need revisiting over time.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.