Normal behavior: When the moderator flags outcome = CORRUPTED with goodFaith = true and names a whitehat, the contract records corruptedClaimDeadline = _firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW (180 days). The whitehat then has that window to call claimAttackerBounty(), design-intent per docs/DESIGN.md §12. After the window, sweepUnclaimedCorrupted() is permissionlessly callable to recover any unclaimed remainder to recoveryAddress.
The specific issue: in ConfidencePool.flagOutcome, both _firstGoodFaithCorruptedAt (line 366) and corruptedClaimDeadline (line 372) are cast to uint32 without any cap on block.timestamp, unlike the neighboring _markRiskWindowStart (t = min(block.timestamp, expiry) at line 807) and _markRiskWindowEnd (line 824) which were deliberately capped to keep their uint32 casts in range. When the first good-faith CORRUPTED flag is mined with block.timestamp > uint32.max - 180 days (activation window 2105-08-11 UTC through 2106-02-07 UTC), the addition in line 372 wraps modulo 2^32, storing a deadline deep in 1970 — well below the current block.timestamp. As a result:
claimAttackerBounty() reverts ClaimWindowExpired immediately at line 437 (comparison uint256 > uint32-zero-extended resolves with block.timestamp > deadline),
sweepUnclaimedCorrupted() becomes permissionlessly callable at the same block (line 459 gate block.timestamp <= corruptedClaimDeadline fails),
the whitehat's entire bounty (the whole pool: snapshotTotalStaked + snapshotTotalBonus) is swept to recoveryAddress in the same block as the flag.
Activation window is fixed and deterministic: any first good-faith CORRUPTED flagOutcome whose block.timestamp falls in [4_279_415_295, 4_294_967_295] (i.e. 2105-08-11 06:28:15 UTC through 2106-02-07 06:28:15 UTC, exactly the last 180 days of uint32 seconds) triggers the bug. flagOutcome has no upper-bound check on block.timestamp, and expiry may legitimately be set up to type(uint32).max (ExpiryTooFar check at line 197 only enforces expiry_ <= uint32.max), so a pool can still be unresolved when the chain reaches the activation window.
The stored deadline wrap is one-directional: it always lands below block.timestamp for any t in the activation window (t + 180d - 2^32 ∈ [~63K, ~12.3M], all ≪ t ≈ 4.3e9), so the bug deterministically fires rather than being probabilistic.
The whitehat named by the moderator receives zero bounty — claimAttackerBounty() reverts ClaimWindowExpired even in the same block as the flag, contradicting the documented CORRUPTED_CLAIM_WINDOW = 180 days reservation in protocol-readme.md L95 and docs/DESIGN.md §12 ("the pool is reserved for the named whitehat for CORRUPTED_CLAIM_WINDOW (180 days)").
The whole pool (snapshotTotalStaked + snapshotTotalBonus, plus any donation dust) is permissionlessly swept to recoveryAddress by any caller via sweepUnclaimedCorrupted() — funds the moderator explicitly intended for the whitehat go to the recovery address instead.
The state is unrecoverable without a contract upgrade: _firstGoodFaithCorruptedAt is single-write (if (_firstGoodFaithCorruptedAt == 0) at line 364), so every subsequent moderator re-flag recomputes corruptedClaimDeadline from the same wrapped base at line 372 and stays broken; no on-chain action by the moderator, staker, or whitehat can restore the 180-day window.
Run: forge test --match-test test_PoC_corruptedClaimDeadline_uint32Wrap -vv
Apply the same expiry-cap pattern that already exists in _markRiskWindowStart / _markRiskWindowEnd (lines 807, 824). Either bound block.timestamp to expiry (the pool's own lifecycle) or guard the addition against the uint32 ceiling:
This widens the addition to uint256 (no wrap) and keeps the stored uint32 value in range by clamping block.timestamp first — matching the existing min(t, expiry) approach used for riskWindowStart / riskWindowEnd.
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.