Severity: High
Component: ghost-preclaims / finder.py @ commit 1538567
Summary
finder.py scans the JML collection with range(start_token_id, total_supply). The collection is 1-indexed (token IDs 1..N), so the token with ID == totalSupply is never queried. Its owner never makes it into the snapshot, forester.py then publishes a Merkle root without them, and there is no way to claim afterwards. Re-running the tool doesn’t fix it — it just prints “All preclaims are already collected.”
The code
def load_existing_snapshot(file_path):
...
return data, last_token_id + 1
...
return [], 1 # fresh run starts at token 1
if start_token_id >= total_supply:
print(f"[+] All preclaims are already collected.")
sys.exit(0)
for token_id in tqdm(range(start_token_id, total_supply), desc=f"Scanning {chain_id}"):
range(1, total_supply) covers 1..N-1. Token N is skipped on every run.
Why this isn’t intended
I checked the actual JML collection on Sepolia (0x91ba8A14D2CC851aBb69212c09f59e06e1e7f0a5): ownerOf(0) reverts with ERC721: invalid token ID while ownerOf(1) returns an owner. So the collection is 1-indexed, the full range is [1, totalSupply], and the loop bound is just wrong. There is no indexing convention under which range(1, totalSupply) is complete.
Two more signs it’s a bug and not a cutoff rule:
The resume path stores last_token_id + 1, which only makes sense if the intent is to cover everything up to and including totalSupply.
With totalSupply == 1, a fresh run prints “All preclaims are already collected.” and exits without writing any snapshot file. That’s obviously not intended output.
Reproduction
Ran the repo’s own finder.py and forester.py unmodified against a local anvil chain, with a minimal mock of the JML interface (totalSupply/ownerOf/collateralBalances) deployed at the hardcoded address via anvil_setCode. Minting 1-indexed, 5 tokens (owners A1, A2, A3, A1, A1).
$ python3 finder.py --rpc http://127.0.0.1:8545 --block latest --delay 0
[+] Current totalSupply: 5
# snapshot token_ids: [1, 2, 3, 4] -> token 5 (== totalSupply) never queried
$ python3 forester.py --network-id 31337
=== FINAL MERKLE ROOT [31337]: aa0a52991b768bacc3c2c2de4331a3db798e9242c074216430e7d97b3d88fe2e ===
# packages only carry token_ids [2, 3, 4] — no leaf/proof exists for token 5
$ python3 finder.py --rpc http://127.0.0.1:8545 --block latest --delay 0 # re-run
[+] All preclaims are already collected.
# exits immediately, token 5 still missing — cannot be repaired by re-running
And with totalSupply == 1: fresh run exits with “All preclaims are already collected.” without scanning the only token, and no snapshot file is written (the early exit sits before save_snapshot).
This already happened in the committed data
snapshots/137.json (Polygon) is missing token 2833 with no way to backfill it — the resume logic starts at max_token_id + 1, so the gap is permanent. Every committed snapshot tops out at totalSupply-1 of its snapshot block. So this isn’t theoretical: published snapshots are silently incomplete.
Impact
Whoever owns the highest-minted token at snapshot time gets no leaf in the Merkle tree, so no proof can ever be generated for them once the root is published. Their preclaim is permanently unclaimable. The progress bar ending at N-1 looks completely normal, so nothing alerts the operator.
Suggested fix
if start_token_id > total_supply: # was >=
for token_id in tqdm(range(start_token_id, total_supply + 1), ...): # was range(start_token_id, total_supply)
Note: existing snapshots are all short by exactly one token — fixing an already-published root means re-snapshotting and re-publishing that collection’s root