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.
View preliminary resultsAppeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.