Normal behavior: each distinct failure condition in the contract should map to its own dedicated custom error, so an off-chain caller can determine the exact cause of a revert purely from the 4-byte selector, without needing extra state reads. Every other claim/sweep entrypoint in this contract (claimCorrupted(), claimAttackerBounty(), sweepUnclaimedCorrupted()) follows this convention correctly, using a distinct error per distinct cause.
Specific issue: claimSurvived() reuses the single generic InvalidAmount() error for two logically unrelated conditions — "the caller already claimed" (hasClaimed[msg.sender] == true) and "the caller never had a claimable stake in this pool" (eligibleStake[msg.sender] == 0) — making the two situations indistinguishable from the revert selector alone.
This is demonstrably an inconsistency rather than a deliberate design choice: the contract's own parallel resolution entrypoint, claimExpired(), handles the exact same two situations differently one function over — hasClaimed still reverts, but eligibleStake == 0 is a silent, non-reverting no-op, by explicit design and comment ("Soft-success: caller had nothing to claim … useful for a non-staker to mechanically auto-resolve the pool"). The authors clearly treat the two causes as meaningfully distinct elsewhere in the same file; claimSurvived() just doesn't expose that distinction in its error selector.
Likelihood:
Reason 1 // A caller who has already successfully claimed via claimSurvived() and calls it again (e.g. a wallet double-submitting a transaction, or a naive retry-on-failure bot) receives InvalidAmount().
Reason 2 // A caller who was never a staker in this specific pool (e.g. a monitoring bot iterating a stale or mismatched address list, or a user who confuses one pool for another) receives the exact same InvalidAmount() selector when calling claimSurvived().
Impact:
Impact 1 // Off-chain integrators (whitehat claim-monitoring bots, indexers, wallet UIs surfacing failure reasons) cannot distinguish "already handled, no action needed" from "this address was never a participant here" without performing two additional, otherwise-avoidable state reads per address (hasClaimed(addr) and eligibleStake(addr)), defeating the diagnostic purpose of using granular custom errors.
Impact 2 // Automated tooling built against this interface may take an incorrect action as a result of the ambiguity (e.g. repeatedly retrying a claim for an address that was simply never a staker, mistaking it for a transient/retryable failure rather than a permanent one).
Apply the identical change to the shared hasClaimed check inside claimExpired()'s claim block (if (hasClaimed[msg.sender]) revert InvalidAmount();), for consistency across both resolution entrypoints. This aligns claimSurvived()/claimExpired() with the one-error-per-cause convention already used correctly by every other claim/sweep function in the contract (claimCorrupted(), claimAttackerBounty(), sweepUnclaimedCorrupted()).
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.