SNARKeling Treasure Hunt

First Flight #59
Beginner FriendlyGameFiFoundry
100 EXP
Submission Details
Impact: medium
Likelihood: high

Noir circuit ALLOWED_TREASURE_HASHES has a duplicate at index 9 and omits treasure #9's hash — making treasure #9 permanently unclaimable and locking 10 ETH

Author Revealed upon completion

Root + Impact

Description

Normal behaviour: the Noir circuit must enforce that a submitted treasure_hash is one of the 10 pre-baked treasure commitments, so participants can only produce valid proofs for the 10 legitimate treasures hidden in the real-world hunt. The Solidity TreasureHunt.sol contract exposes a matching MAX_TREASURES = 10 and is funded with 10 × 10 ETH = 100 ETH so each legitimate finder gets a 10 ETH reward.

Specific issue: ALLOWED_TREASURE_HASHES in circuits/src/main.nr lists only 9 unique hashes — the last two entries (index 8 and index 9) are byte-for-byte identical. Cross-referencing with the canonical list in contracts/scripts/Deploy.s.sol (lines 17-26) confirms that the correct index-8 hash — -4417726114039171734934559783368726413190541565291523767661452385022043124552 (the hash of treasure secret #9) — is missing from the circuit and was replaced by a second copy of treasure #10's hash -961435057317293580094826482786572873533235701183329831124091847635547871092.

// circuits/src/main.nr
global ALLOWED_TREASURE_HASHES: [Field; 10] = [
1505662313093145631275418581390771847921541863527840230091007112166041775502,
-7876059170207639417138377068663245559360606207000570753582208706879316183353,
-5602859741022561807370900516277986970516538128871954257532197637239594541050,
2256689276847399345359792277406644462014723416398290212952821205940959307205,
10311210168613568792124008431580767227982446451742366771285792060556636004770,
-5697637861416433807484703347699404695743570043365849280798663758395067508,
-2009295789879562882359281321158573810642695913475210803991480097462832104806,
8931814952839857299896840311953754931787080333405300398787637512717059406908,
// @> index 8: should be -4417726114039171734934559783368726413190541565291523767661452385022043124552
// @> (the hash of treasure #9, per Deploy.s.sol line 25)
-961435057317293580094826482786572873533235701183329831124091847635547871092,
// @> index 9: same value duplicated instead of being a distinct entry
-961435057317293580094826482786572873533235701183329831124091847635547871092
];

Because is_allowed(hash) iterates over the 10 slots and only returns true if hash equals one of the stored values, the hash of treasure #9 (pedersen_hash of secret 9) can never satisfy the circuit's membership check. The Noir main circuit therefore cannot produce a valid proof for treasure #9, and the Solidity claim() function can never accept one.

Risk

Likelihood:

  • Certain — the duplicate is compiled into the circuit at deployment time; any participant who physically finds treasure #9 and tries to claim their reward fails proof generation immediately (is_allowed returns false).

  • The bug is deterministic: no adversary, no timing, no edge case needed. It will manifest the first time a real participant locates treasure #9.

Impact:

  • 10 ETH effectively locked in the contract: 10 rewards were funded but only 9 can ever be claimed. The 10th reward sits idle.

  • claimsCount can never reach MAX_TREASURES = 10, so the post-hunt withdraw() path (which gates on claimsCount >= MAX_TREASURES) is unreachable. The owner is forced to rely on the emergency pathway (pause() + emergencyWithdraw) to recover the stranded 10 ETH, which is not the intended operational flow.

  • Reputational / UX damage: the participant who locates treasure #9 in the real world believes they have found a valid treasure, invests the effort to generate a proof, and is inexplicably denied — with no way of knowing that the underlying circuit is to blame.

Proof of Concept

  1. Inspect circuits/src/main.nr lines 55-66 and compare with contracts/scripts/Deploy.s.sol lines 17-26. The canonical list in Deploy.s.sol has 10 distinct hashes; the circuit array has ALLOWED_TREASURE_HASHES[8] == ALLOWED_TREASURE_HASHES[9].

  2. Resulting membership check in the circuit:

fn is_allowed(hash: Field) -> bool {
let mut ok = false;
for i in 0..10 {
if hash == ALLOWED_TREASURE_HASHES[i] {
ok = true;
}
}
ok
}

Let H9 = pedersen_hash(9) be the canonical hash of treasure secret #9. Because H9 == -4417726...552 and -4417726...552 is absent from the circuit array, iterating over the 10 slots never finds a match. is_allowed(H9) returns false and the circuit's assertion assert(is_allowed(treasure_hash)) fails. No valid proof can be generated for treasure #9, so TreasureHunt.claim(proof, H9, recipient) can never succeed.

  1. Observable end-state:

  • claimsCount caps at 9 (the other 9 distinct secrets still work).

  • address(hunt).balance permanently holds ≥ 10 ETH after all possible claims.

  • withdraw() reverts with HUNT_NOT_OVER forever.

Recommended Mitigation

Replace index 8 with the correct hash of treasure #9 and keep index 9 as the hash of treasure #10:

global ALLOWED_TREASURE_HASHES: [Field; 10] = [
1505662313093145631275418581390771847921541863527840230091007112166041775502,
-7876059170207639417138377068663245559360606207000570753582208706879316183353,
-5602859741022561807370900516277986970516538128871954257532197637239594541050,
2256689276847399345359792277406644462014723416398290212952821205940959307205,
10311210168613568792124008431580767227982446451742366771285792060556636004770,
-5697637861416433807484703347699404695743570043365849280798663758395067508,
-2009295789879562882359281321158573810642695913475210803991480097462832104806,
8931814952839857299896840311953754931787080333405300398787637512717059406908,
- -961435057317293580094826482786572873533235701183329831124091847635547871092,
+ -4417726114039171734934559783368726413190541565291523767661452385022043124552,
-961435057317293580094826482786572873533235701183329831124091847635547871092
];

After the fix, the circuit accepts proofs for all 10 distinct treasures, the full 100 ETH treasury can be distributed, and claimsCount reaches MAX_TREASURES so the withdraw() post-hunt flow becomes reachable.

Additional hardening (defence in depth): add a compile-time check / unit test in the Noir project that asserts ALLOWED_TREASURE_HASHES contains 10 distinct entries (the Noir test file tests.nr already exists in the repo and is the natural place for this invariant). A similar off-chain parity test should confirm the circuit's array matches the deployment script's list element-for-element, so any future edit to one without the other is caught immediately.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!