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