stake() can accept an amount for which the stored amount * entryTime^2 moment fits in
uint256, while _bonusShare() later overflows when it adds two individually valid absolute-time
products before subtracting them:
The mathematical score amount * (T - entryTime)^2 may be zero or otherwise fit in uint256, but
the expanded positive intermediate can still exceed type(uint256).max. Solidity reverts before
the subtraction and before the documented globalScore == 0 fallback can execute.
After a SURVIVED resolution, every staker claim evaluates the same overflowing global
denominator. No staker can recover principal or bonus, sweepUnclaimedBonus() reserves the entire
accounted balance, and the terminal PRODUCTION registry state prevents recovery through a
CORRUPTED re-flag. The pool's accounted principal and bonus are permanently locked.
The same root cause also affects EXPIRED claims and can revert earlier in
_markRiskWindowStart(), preventing the permissionless expiry backstop from resolving a pool while
the registry remains in its normal UNDER_ATTACK state.
The pool records absolute Unix-time moments when stake is deposited:
This checked multiplication only implicitly bounds an accepted deposit relative to its current
entry timestamp. When the risk window opens, the global second moment is reset using a potentially
later timestamp:
At settlement, _bonusShare() expands the square into absolute-time terms:
For an effective entry time E, an accepted total stake can satisfy:
The right side lets the entry-time moment fit, while the left side guarantees that the later
plus addition overflows. Each product can fit independently; it is their addition that reverts.
The issue does not require elapsed risk time. If the risk window opens and closes at the same
timestamp, the correct mathematical score is exactly zero:
docs/DESIGN.md section 7 expressly requires _bonusShare() to use an amount-weighted split when
globalScore == 0. However, the implementation first evaluates:
and can overflow before line 712 checks globalScore == 0. The fallback therefore does not cover
the full accepted input domain it was designed to handle.
At the repository timestamp, the primary PoC requires approximately 1.887e58 base units. This is
economically unrealistic for a conventional 18-decimal asset, so practical likelihood depends on
the allowlisted token's unit scale. It is approximately 0.01887 whole token for a 60-decimal
asset.
This does not require fee-on-transfer behavior, rebasing, callbacks, or a malicious token. ERC20
transfers operate on raw uint256 base units, while decimals() is display metadata. A plain
OpenZeppelin ERC20 can use the relevant scale by overriding only:
The factory validates neither decimals nor a maximum aggregate stake. Allowlisting is the normal
setup path for every supported token, and the protocol documentation excludes non-standard
transfer accounting but does not exclude a plain high-decimal ERC20. Therefore, the required
state is unusual but remains inside the declared token model.
Only one bonus base unit is needed to bypass _bonusShare()'s snapshotTotalBonus == 0 early
return. The unsafe aggregate stake may also be reached collectively rather than by one account.
claimSurvived() always calls _bonusShare() and reverts on the overflowing global score.
A staker calling claimExpired() while unresolved reverts the entire resolution transaction.
A non-staker can finalize EXPIRED or auto-SURVIVED through the zero-stake return, but every
subsequent staker claim still executes _bonusShare() and reverts.
Re-flagging SURVIVED snapshots the same accounting and cannot repair the arithmetic.
After registry PRODUCTION, the moderator cannot re-flag the pool as CORRUPTED.
sweepUnclaimedBonus() reserves
totalEligibleStake + snapshotTotalBonus - claimedBonus. Since no claim succeeds, this equals
the entire accounted token balance and the sweep reverts with NothingToSweep.
Post-resolution donations may be swept, but no accounted principal or bonus can leave.
Impact: complete and permanent loss of access to every staker's principal and bonus in the
affected SURVIVED/finalized EXPIRED pool.
Attacker privilege: none. stake() and contributeBonus() are public protocol operations.
Token behavior: ordinary ERC20 transfer semantics are sufficient.
Economic precondition: the aggregate raw token balance must cross the unsafe arithmetic
threshold. This is prohibitive for conventional 18-decimal assets but small in whole-token terms
for a supported high-decimal asset.
Additional effect: a stake that fits at deposit time can overflow the later
_markRiskWindowStart() reset, blocking expiry resolution while UNDER_ATTACK and causing the
observed-risk bonus accounting to be lost if the registry later becomes terminal.
Place the following test in test/audit/ConfidencePoolScoreOverflow.t.sol:
Run:
The complete regression file contains five passing tests:
These tests cover moderator SURVIVED, mechanical EXPIRED, a zero-duration risk window, failure
of the recovery sweep, and overflow during the first active-risk observation.
Prefer storing moments relative to riskWindowStart instead of expanding squares over absolute
Unix timestamps. This preserves substantially more arithmetic range and computes the quantity the
protocol actually needs.
As a minimal defensive fix, enforce a conservative aggregate cap after measuring the tokens
actually received and before updating any moments:
The cap guarantees that both positive terms, their addition, and the corresponding doubled
negative term remain representable for every permitted uint32 timestamp. Any final fix should
bound every expanded intermediate, not only the final Math.mulDiv operation.
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.