Root Cause:
_markRiskWindowStart()/_markRiskWindowEnd() cap their sealed timestamp at expiry (if (t > expiry) t = expiry), and risk-window observation is lazy — it only happens when someone calls a pool function, not automatically when the upstream registry transitions. Since BattleChain's UNDER_ATTACK state has no on-chain timeout (confirmed against the upstream AttackRegistry), an agreement can remain under attack indefinitely — well past a given pool's own expiry. If nobody interacts with the pool during that entire active-risk window, the first post-expiry call is what seals riskWindowStart for the first time, and it lands exactly at expiry. Because T for the EXPIRED path is also expiry, every staker's (T - entryTime)² term becomes (expiry - expiry)² = 0 simultaneously, driving globalScore to zero and triggering _bonusShare's amount-weighted fallback for the entire pool.
Impact:
The k=2 formula's core incentive — reward early, long-duration risk-takers more than late entrants — silently degrades to a flat pro-rata split by stake amount alone, for every staker in the pool, not just late ones.
No principal or bonus is lost from the pool; the total payout is identical, only its distribution among stakers changes versus what the formula was designed to produce.
Not attacker-directable: any single stake, withdraw, or a permissionless pokeRiskWindow() call from any party during the active-risk window breaks the setup by sealing riskWindowStart earlier — so this is an organic liveness/timing gap rather than something one party can engineer against another for gain.
Normal behavior:
The bonus pool is meant to be split among stakers using a k=2 time-weighted formula (amount × (T − entryTime)²) so that earlier, longer-duration risk-takers receive a disproportionately larger share than late entrants — deliberately "crushing" late joiners' bonus. T and riskWindowStart are meant to bound this weighting to the interval during which the agreement was genuinely in an active-risk state, each capped at expiry so the accrual window can't extend past the pool's own lifecycle.
The issue:
Risk-window observation is lazy (only recorded when a pool function is called) and BattleChain's UNDER_ATTACK state has no on-chain timeout, so an agreement can remain under attack indefinitely, well past a given pool's expiry. If no one interacts with the pool during that entire window, the first post-expiry call is what seals riskWindowStart for the first time — and the expiry-cap forces that seal to land exactly at expiry itself. Since T for the EXPIRED path is also expiry, every staker's (T − entryTime)² term becomes zero simultaneously, globalScore collapses to zero, and _bonusShare falls back to a flat amount-weighted split — silently erasing the time-weighting for every staker in the pool rather than just the late ones it was designed to penalize.
Likelihood:
Occurs whenever a pool's expiry is set shorter than the actual duration the underlying agreement spends in UNDER_ATTACK, which is a real and expected scenario since the BattleChain AttackRegistry places no timeout on that state and protocols are documented to use long-running attack phases.
Occurs whenever the risk window closes and reopens/observes without any staker, withdrawer, or third party calling pokeRiskWindow() (or any other pool function) during the active-risk period — a passive, no-action default rather than something requiring deliberate manipulation.
Impact:
Every staker's bonus share collapses to a flat amount-weighted split, regardless of how early or how long they were genuinely at risk, silently erasing the core early-staker incentive the k=2 formula exists to provide.
Early, long-duration risk-takers receive materially less bonus than the formula promises, while late entrants receive materially more than the formula was designed to allow them — a redistribution among stakers that contradicts the pool's advertised mechanics, even though total payout and principal remain intact.
Drop it into test/unit/ — it extends the repo's own BaseConfidencePoolTest so it picks up the existing mocks automatically.
Given this touches the same cap _markRiskWindowEnd relies on and the "accrual bounded by pool lifecycle" invariant the current code leans on elsewhere, I'd flag this one to the sponsor as a discussion point rather than a drop-in patch — the minimal, lowest-risk alternative is to leave the math untouched and instead document the scenario explicitly in DESIGN.md (since total payout/principal safety isn't affected, only the intra-staker bonus split), pushing this from "needs a code fix" to "needs an acknowledged limitation," which is a legitimate response the sponsor may prefer given how load-bearing the expiry-cap is elsewhere in the contract.
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.