The same bonus formula uses two different T upper bounds depending on the resolution outcome — riskWindowEnd for SURVIVED and expiry for EXPIRED. Since riskWindowEnd is always ≤ expiry, the ratio (T − entry)^2 shifts between outcomes, redistributing bonus from early to late stakers without their knowledge. Stakers who entered early expecting a time-weighted premium receive less under EXPIRED than under SURVIVED for identical stake positions.
The k=2 bonus formula treats every staker's score as proportional to amount × (T − entryTime)², where T = outcomeFlaggedAt. The protocol sets T differently depending on which resolution branch fires inside claimExpired().
When flagOutcome(SURVIVED) is called, or when claimExpired() resolves as SURVIVED, the upper bound is pinned to riskWindowEnd — the block timestamp when the registry was first observed in a terminal state. This is the canonical "at-risk" upper bound. When claimExpired() resolves as EXPIRED, the upper bound is pinned to expiry — the pool's fixed deadline. The code at line 569 assigns this value:
And the SURVIVED branch at line 559:
Because _markRiskWindowEnd() caps riskWindowEnd at expiry, the inequality riskWindowEnd ≤ expiry always holds. The gap expiry − riskWindowEnd can be large — months, in practice — because the registry may reach a terminal state long before the pool's stated deadline. The ratio (T − entryEarly) / (T − entryLate) grows with T, meaning early stakers lose relative share as T increases.
Mathematically, for staker with entry t_a and staker with entry t_b where t_a < t_b:
SURVIVED share: (T1 − t_a)² / ((T1 − t_a)² + (T1 − t_b)²)
EXPIRED share: (T2 − t_a)² / ((T2 − t_a)² + (T2 − t_b)²)
Since T2 > T1, the derivative ∂/∂T [ (T − t_a)² / ((T − t_a)² + (T − t_b)²) ] < 0 when t_a < t_b — the early staker's share strictly decreases as T increases. The late staker's share increases symmetrically. The total bonus pool is fixed (snapshotTotalBonus), so this is a zero-sum redistribution from early to late entrants.
Likelihood:
A terminal registry state is reached before the pool's expiry, creating a gap expiry − riskWindowEnd > 0. This is the normal lifecycle — agreements typically resolve weeks before the staker deadline.
An early staker (large expiry − entryTime) sees their bonus reduced whenever the pool resolves as EXPIRED instead of SURVIVED. The loss grows with the gap between riskWindowEnd and expiry.
Impact:
Early stakers systematically receive less bonus than the time-weighted formula implies. Under SURVIVED (T=500) Alice gets 100% of a 1000-token bonus pool against Bob; under EXPIRED (T~31 days) she gets only 50%. The loss is 50 percentage points.
The difference is invisible at deposit time — stakers cannot know which outcome will fire. The outcome is determined by the live registry state at the first post-expiry call.
The test deploys two independent ConfidencePool clones, each backed by its own MockAttackRegistry so they have independent state machines. Both pools are initialized with the same parameters: 31-day expiry, 1-token minimum, and the same moderator/recovery addresses.
Timeline and math:
t = +100 seconds: Both registries transition to UNDER_ATTACK. Both pools are poked, sealing riskWindowStart = BASE + 100. Alice deposits 100 tokens into each pool. At this point the risk window is open and both deposits are post-risk, so _clampUserSums does not fire (entry = riskWindowStart).
t = +300 seconds: Bob deposits 100 tokens into each pool. Bob's entry time is 200 seconds later than Alice's.
t = +500 seconds: Pool A's registry transitions to PRODUCTION. Pool A is poked, sealing riskWindowEnd = BASE + 500. Pool B stays in UNDER_ATTACK.
Pool A resolution (SURVIVED): Moderator calls flagOutcome(SURVIVED). The bonus formula uses T = outcomeFlaggedAt = riskWindowEnd = BASE + 500. Alice's gap = 500 − 300 = 200 (floored entry). Bob's gap = 500 − 300 = 200 as well — wait, both have the same entry because both deposited after riskWindowStart. Actually Bob's entry = BASE + 300. Alice's entry = BASE + 100 (floored from riskWindowStart = BASE+100). So:
Alice: (500 − 100)² × 100 = 400² × 100 = 16,000,000
Bob: (500 − 300)² × 100 = 200² × 100 = 4,000,000
Alice gets 80% of the bonus.
Pool B resolution (EXPIRED): Registry is still UNDER_ATTACK. Warp past expiry. claimExpired() sees the non-terminal state and falls through to EXPIRED. The bonus formula uses T = outcomeFlaggedAt = expiry = BASE + 31 days approv. 2,678,400 seconds. Alice's gap = 2,678,400 − 100. Bob's gap = 2,678,400 − 300. The 200-second difference between them is dwarfed by the 31-day T:
Alice: (2,678,400 − 100)² × 100 approv. (2,678,300)² × 100
Bob: (2,678,400 − 300)² × 100 approv. (2,678,100)² × 100
The ratio is nearly 1:1. Both get approv. 500 tokens.
Assertion: Under SURVIVED (small T = riskWindowEnd), Alice dominates the bonus pool with her earlier entry. Under EXPIRED (large T = expiry), the 200-second timing gap is compressed to near-zero, splitting the bonus almost equally. Early stakers lose their time-weighted advantage when the pool resolves as EXPIRED.
Alice receives 1000 tokens under SURVIVED but only 500 tokens under EXPIRED — a 500-token swing. Bob receives 0 tokens under SURVIVED but 500 tokens under EXPIRED.
Use the same T anchor for both SURVIVED and EXPIRED. When EXPIRED fires and riskWindowEnd > 0, use riskWindowEnd. When riskWindowEnd == 0, fall back to expiry. This keeps the at-risk upper bound consistent across outcomes while preserving the fallback for pools that never observed a terminal registry state.
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.