Normal behavior: TreasureHunt.claim(proof, treasureHash, recipient) at contracts/src/TreasureHunt.sol:83-112 should pay out REWARD ETH to recipient exactly once per treasureHash. After a successful claim the claimed[treasureHash] mapping entry is set, and subsequent calls with the same treasureHash must revert with AlreadyClaimed(treasureHash).
Specific issue: The duplicate-claim guard at L88 references the wrong variable. contracts/src/TreasureHunt.sol:35 declares bytes32 private immutable _treasureHash; which is never assigned in the constructor (L67-L75), so it defaults to bytes32(0) in the deployed bytecode. The guard at L88 reads claimed[_treasureHash] (i.e. claimed[bytes32(0)]), while the mark at L104 via _markClaimed correctly writes claimed[<parameter treasureHash>] = true. Because the keys do not intersect for any non-zero treasureHash, the guard never fires.
Likelihood: HIGH
Reason 1: Any participant who finds a single valid treasure secret (the intended honest-participant path) is able to execute the exploit. No special tooling, no privileged access. A 3-line change to the attack script.
Reason 2: The calldata of a successful claim is public on-chain, so anyone — not just the original finder — can observe (proof, treasureHash, recipient) and replay it from a different msg.sender (satisfying the recipient != msg.sender check by using a distinct EOA).
Impact: HIGH
Impact 1: Direct theft of 100% of protocol funds. A single valid secret drains the full MAX_TREASURES × REWARD = 10 × 10 ETH = 100 ETH funding.
Impact 2: Every other legitimate treasure finder is deprived of their 10 ETH reward — there is no ETH left once the bug is triggered.
The following Foundry test uses a MockVerifier shim (a minimal IVerifier implementation that returns true for any non-empty proof + honours setExpectedRecipientField for binding) to isolate the Solidity-level bug from the real Barretenberg-generated verifier. The real verifier enforces proof-to-public-input binding, which the shim also enforces via expectedRecipientField for realism.
Both tests pass under forge test. Slither's uninitialized-state detector directly flags TreasureHunt._treasureHash as "never initialized. It is used in: TreasureHunt.claim(bytes,bytes32,address)". Additionally, the contest's own existing test testClaimDoubleSpendReverts at contracts/test/TreasureHunt.t.sol:134-147 has //vm.expectRevert(); commented out — i.e. the test is named as if it expected a revert, but actually asserts the double-spend succeeds under the bug.
The one-character fix (_treasureHash → treasureHash) on L88 restores the intended replay guard. The unused immutable at L35 should also be deleted for clarity.
As a regression guard, uncomment the existing //vm.expectRevert(); in contracts/test/TreasureHunt.t.sol:144 so that the testClaimDoubleSpendReverts test actually asserts the intended behavior:
In `claim()`, the guard uses `claimed[_treasureHash]`, where `_treasureHash` is an immutable state variable that is never initialized to the caller-supplied treasure identifier, while the contract later marks `claimed[treasureHash] = true` using the function argument instead. As a result, the duplicate-claim check and the state update are performed against different keys, which means a previously claimed treasure is not actually blocked from being claimed again with the same valid proof and `treasureHash`. This breaks a core invariant of the protocol described in the README, namely, that each treasure can only be redeemed once, and allows one valid treasure/proof pair to be reused to drain rewards repeatedly until either the `MAX_TREASURES` cap or the contract balance is exhausted.
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.