The pool accepts arbitrarily large standard ERC20 stakes. The root cause is that
accepted stake amounts are later multiplied by squared timestamps in
_bonusShare() and _markRiskWindowStart() without a stake cap or 512-bit
intermediate arithmetic, so an amount that fits at deposit time can overflow
after time advances and freeze claims or erase observed-risk bonus accounting.
The protocol documents the bonus as a k=2 time-weighted formula, where each
deposit contributes amount * (T - entryTime)^2. The implementation stores
equivalent expanded sums instead of iterating deposits at claim time. When a user
stakes, the pool accepts the post-transfer received amount and records
received * newEntry * newEntry without any maximum stake bound.
That first multiplication only proves the amount fits at the current
block.timestamp. It does not prove the same amount will fit after T advances
toward expiry. _bonusShare() later recomputes both the user's score and the
global score using the larger resolution timestamp. The code multiplies
T * T * userEligible and T * T * snapshotTotalStaked directly:
The comment below this block says Math.mulDiv protects the final
share multiplication from overflow, but the dangerous timestamp-squared
products already happened before mulDiv is reached. A large staker can choose
an amount slightly above type(uint256).max / (expiry * expiry), while still
keeping the original amount * stakeTimestamp * stakeTimestamp below the
uint256 limit. Once the pool resolves near expiry, every claimSurvived() or
claimExpired() call that enters _bonusShare() reverts on arithmetic
overflow.
The same class of overflow exists when the risk window opens. _markRiskWindowStart()
eagerly rewrites the global accumulators as if all current stake entered at the observed risk timestamp:
Again, totalEligibleStake * t * t can overflow even though every earlier stake
was accepted. If that happens on the first active-risk observation, pokeRiskWindow(),
stake(), contributeBonus(), withdraw(), or claimExpired() can revert
before sealing riskWindowStart.
That second path is economically important because the contract has a special
no-observed-risk branch. If the overflow prevents the pool from observing the
active-risk interval and the next successful interaction only sees PRODUCTION,
riskWindowStart stays zero. _bonusShare() then returns zero for stakers and
sweepUnclaimedBonus() treats the entire bonus as sweepable to
recoveryAddress.
This is not a hostile-token issue. The PoC uses the normal mock ERC20 transfer
path and only relies on a large standard token balance. The README limits tokens
to standard ERC20 semantics, but it does not impose a maximum supply, decimals,
stake amount, or pool TVL cap that would keep the quadratic arithmetic inside
uint256.
An attacker with a sufficiently large standard-token balance freezes all
SURVIVED or EXPIRED claims for the affected pool by making _bonusShare() revert
for every claimant. In the risk-observation variant, the attacker prevents
riskWindowStart from being sealed, causing stakers to receive principal only
while the full bonus is swept to recoveryAddress. Both paths break the core
settlement invariant that accepted stake remains claimable after a correct
SURVIVED or EXPIRED outcome.
Bound accepted stake so every future amount * timestamp^2 calculation fits, or
rewrite the score calculation around bounded deltas (T - entryTime) and
512-bit arithmetic before any timestamp-squared product can overflow.
I reproduced the issue locally with a standalone Foundry test. Save the PoC as
test/TempRiskWindowStartOverflowPoC.t.sol and run:
Representative output:
Full PoC:
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.