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