Normally, _bonusShare() computes each staker's k=2 score and the pool-wide global score using
plain checked uint256 arithmetic, then hands the final ratio to Math.mulDiv for the
multiply-then-divide step. Math.mulDiv is what protects that last step from overflowing on a
large snapshotTotalBonus (per the code's own comment on that line), but it does not, and cannot,
protect the score terms computed before it.
stake() only proves that a single deposit's own moments fit in uint256 at deposit time
(received * newEntry * newEntry). It never proves anything about the aggregate
T² · totalEligibleStake term _bonusShare later computes at claim time, where T is the pool's
frozen terminal timestamp (potentially far later than, and unrelated in magnitude to, any single
deposit's own entry time) and totalEligibleStake is the sum across every staker, not one. A
single staker's sufficiently large balance can make that aggregate term overflow even though
their own deposit-time computation never came close to overflowing.
Because claimSurvived() computes _bonusShare before transferring anything, an overflow here
reverts the whole claim transaction, for every staker, not just the one holding the outsized
balance, and there is no alternate payout path once the outcome is finalized (withdraw() is
already permanently closed by that point).
Likelihood:
Occurs only when a single staker's balance is large enough to push
outcomeFlaggedAt² · totalEligibleStake past type(uint256).max. At outcomeFlaggedAt's
absolute ceiling (type(uint32).max, the largest timestamp the pool's uint32 fields can ever
hold), that requires an aggregate stake on the order of ~1.9 × 10⁵⁸ wei, roughly 1.9 × 10⁴⁰
tokens at 18 decimals. No real ERC20's total supply reaches within ~25 orders of magnitude of
that figure; the PoC below only reaches it because the test harness's mock token has unrestricted
minting. Every pool's stakeToken is additionally owner-vetted through the factory's
allowedStakeToken allowlist before it can ever be used, which is the same trust boundary the
codebase already leans on for excluding non-standard token behavior (fee-on-transfer, rebasing).
This is a materially different likelihood profile than the registry-rewind finding (M-02) in this
same pass: that one required only realistic stake amounts and an externally-plausible event
(a registry migration the team's own design doc treats as expected). This one requires a single
balance no legitimate token, at any decimals count, could ever produce.
Impact:
If the precondition were ever met, the impact is severe: every ordinary staker's claimSurvived()
reverts permanently, with principal and bonus both stuck (a full claim-path denial of service),
and the staker holding the oversized balance loses their own principal too, so even a
deliberately malicious actor gains nothing from triggering it (pure griefing, not profitable
theft).
Rated Low rather than Medium/High specifically because the triggering input is not achievable by
any real, honestly-supplied ERC20 token; it would require either an already out-of-scope
malicious stake token (a different, already-documented trust assumption, "Pools assume a
standard ERC20") or a token supply that does not exist for any real fungible asset.
Checked into the repo as a real, runnable file at
test/unit/ConfidencePool.largeStakeOverflow.t.sol:
Confirmed the referenced-but-nonexistent-in-repo test path (test/unit/ConfidencePool.poc.t.sol,
cited by a prior draft of this report) does not exist; this PoC was written fresh against the
existing BaseConfidencePoolTest harness and verified to reproduce the described panic.
Bound the aggregate stake the pool will accept so the k=2 score terms are provably safe at the
formula's known worst case (T at its uint32 ceiling), rather than relying on individual
deposits happening to fit:
Where _MAX_AGGREGATE_STAKE is derived once as type(uint256).max / (2 * uint256(type(uint32).max) ** 2)
(covering both the multiplication and the addition in plus/userPlus), or equivalently, evaluate
the score with an overflow-safe/wide-multiplication helper (e.g. computing T² · stake via
Math.mulDiv against a fixed denominator of 1) instead of raw uint256 multiplication, so the
formula degrades gracefully instead of reverting a way for a legitimate, if unusually large, staker
to become unable to claim.
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.