_observePoolState() updates the risk-window start and end with independent guards. If the registry reaches PRODUCTION or CORRUPTED without an active-risk state ever being observed, the pool writes riskWindowEnd but leaves riskWindowStart at zero. The pair therefore no longer has the ordinary meaning of two bounds belonging to the same interval.
The current implementation deliberately treats a zero start as “no observable risk”: stakers receive no bonus and the contributed bonus can be swept to recovery. The immediate effect is consequently limited to an internally asymmetric representation and a latent integration hazard for future code that reads the nonzero end without also checking the start.
One source report additionally claimed that repeated claimExpired() calls could write markers after resolution. That portion is stale for the reviewed source. claimExpired() now calls _observePoolState() only while the outcome is unresolved, and pokeRiskWindow() returns immediately after resolution. The PoC includes a regression test proving that both markers and outcomeFlaggedAt remain frozen. This finding is therefore partially confirmed: marker asymmetry is reproducible, but post-resolution mutation is not.
The start marker is written only for UNDER_ATTACK or PROMOTION_REQUESTED, whereas the end marker is written only for PRODUCTION or CORRUPTED. Because the checks are independent, directly observing a terminal state creates the persistent combination riskWindowStart == 0 && riskWindowEnd != 0.
This representation is internally handled today, but its structural meaning is easy to misread: a nonzero end normally suggests that a risk interval existed. A future reward formula, view helper, indexer, or upgrade of surrounding infrastructure that checks only riskWindowEnd or outcomeFlaggedAt could treat the timestamp as the end of a window beginning at zero, or otherwise diverge from the pool's “no observable risk” rule.
The suspected post-resolution path is already defended as follows:
Likelihood:
A direct terminal observation without an observed active-risk transition is supported by the state machine and reproducibly creates the asymmetric pair.
Current payout and sweep paths consistently check riskWindowStart, so exploitation requires a future or external consumer to infer semantics from the end marker alone.
Post-resolution mutation is not reachable through the two cited entrypoints in the reviewed version.
Impact:
There is no current principal loss. A zero start intentionally pays no bonus, and the PoC confirms that the bonus remains sweepable.
The partial representation weakens the storage invariant and can cause incorrect accounting or analytics if a later consumer assumes a nonzero end implies a valid window.
Retaining the existing post-resolution guards is important because reopening either marker after snapshots are frozen would make live markers diverge from resolution accounting.
Create test/audit/CP032RiskWindowMarkerAsymmetry.t.sol with the following contents:
Run the PoC from the repository root:
Execute forge test --offline --match-path test/audit/CP032RiskWindowMarkerAsymmetry.t.sol -vv.
Confirm that both tests pass. The first test produces riskWindowStart == 0 and a nonzero riskWindowEnd, then shows that the staker receives principal only and the full bonus sweeps to recovery. The second resolves with both markers at zero, changes the registry after resolution, calls both relevant entrypoints, and confirms that the markers and resolution timestamp do not change.
Represent the semantic state explicitly instead of encoding “no observed risk” as one zero marker and one nonzero marker. An enum avoids making existing payout code interpret a fabricated start timestamp; simply setting riskWindowStart = riskWindowEnd would be unsafe because current code treats every nonzero start as evidence that bonus eligibility existed.
If adding storage is undesirable, document the partial pair as a formal invariant and expose a view such as hasObservedRiskWindow() that all internal and external consumers use. Preserve the current outcome == UNRESOLVED guards in claimExpired() and pokeRiskWindow(), and keep regression tests that verify both markers and outcomeFlaggedAt remain immutable after resolution.
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.