[ghost-preclaims] forester.py silently drops claims for addresses holding multiple NFTs (last-write-wins)

Component: ghost-preclaims @ 1538567b4651f9daddef534192723ff671693d35 — forester.py (around lines 122-134).

Description

output_packages is keyed by address, so when one address owns several NFTs the later entries deterministically overwrite the earlier ones:

for item in snapshot_data:
    ...
    output_packages[item['address']] = {
        "index": item["index"],
        "token_id": item["token_id"],
        "shares": item["shares"],
        "merkle_proof": ...
    }

Only the last-processed NFT per address keeps a proof. Every other position owned by the same address can never be claimed. This is a correctness bug with direct claim impact, not nondeterminism: re-running is byte-identical (verified across two different PYTHONHASHSEED values).

Impact

Multi-NFT owners lose every position except the last one, silently. Verified against the committed production data: snapshots/11155111.json holds 145 items but preclaims/11155111.json has only 139 keys. Concrete case: 0x2E65b5A2c13688046165423cC2174b922B887bA5 holds 7 tokens (indexes 4, 7, 8, 11, 15, 30, 33) but only index 33 gets a Merkle proof, so 6 claims are unclaimable. Duplicate addresses also exist in several other committed snapshot files (e.g. 1.json, 137.json, 56.json), so the data loss is not limited to one chain.

Reproduction

Local only (Python venv, no network), run from the repo root:

import json
from collections import Counter

snap = json.load(open("snapshots/11155111.json"))
d = json.load(open("preclaims/11155111.json"))
print("snapshot items:", len(snap), "| preclaim keys:", len(d))
# -> snapshot items: 145 | preclaim keys: 139

c = Counter(x["address"] for x in snap)
a = [x for x, n in c.items() if n > 1][0]
print(a, [(x["index"], x["token_id"]) for x in snap if x["address"] == a])
print("kept:", d[a]["index"], d[a]["token_id"])
# -> 0x2E65b5A2c13688046165423cC2174b922B887bA5 [(4,5),(7,8),(8,9),(11,12),(15,16),(30,31),(33,34)] kept: 33 34

Severity: High

Reason: real, verified loss of claims in committed production data (6 claims lost for one address alone). Not Critical because no third party can steal funds; the loss is claims the rightful owner cannot make.

Recommendation

Key output_packages by index/token_id instead of by address, or make each per-address value a list of per-token proofs. Re-run forester.py over the existing snapshots to regenerate complete preclaim files.

Verification status

VERIFIED. Reproduced locally against the committed data files; deterministic.

Thank you for your report!

That’s a good observation, but at the same time, holding more than one NFT under the same address was discouraged and effectively disallowed. For now, this is more of a business-development concern, but we’ll keep track of it.

To be clear, this isn’t an issue - it’s more a matter of community decision-making.