feat(merkle-tree-token-claimer): add pinocchio example - #714
Conversation
Greptile SummaryThe PR adds a Pinocchio implementation of the Merkle-tree token claimer, including initialization, root updates, proof-backed claims, receipt tracking, and LiteSVM coverage.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Reviews (2): Last reviewed commit: "merkle-tree-token-claimer: create PDAs o..." | Re-trigger Greptile |
| from: signer, | ||
| to: claim_receipt, | ||
| lamports: Rent::get()?.try_minimum_balance(CLAIM_RECEIPT_SIZE)?, | ||
| space: CLAIM_RECEIPT_SIZE as u64, | ||
| owner: program_id, | ||
| } | ||
| .invoke_signed(&[Signer::from(&receipt_seeds)])?; | ||
| } |
There was a problem hiding this comment.
Pre-funded receipts block claims
When an attacker transfers lamports to a publicly derivable receipt PDA before its claim, the account remains data-empty and this branch invokes CreateAccount over its existing balance, causing the System Program to reject creation and preventing the eligible wallet from claiming that index.
How this was verified: The receipt address is publicly derivable, a lamport-only account remains data-empty, and the changed branch invokes CreateAccount without accommodating an existing balance.
Knowledge Base Used:
There was a problem hiding this comment.
Good catch — fixed in ae2e211c, and this one is a regression against the reference rather than something inherited: Anchor's init already falls back to transfer + allocate + assign when the address holds lamports, and my CreateAccount did not.
Both PDAs this program creates now go through a create_pda_account helper that tops the account up to rent exemption, then allocates and assigns it. Applied to the airdrop state as well as the receipt, since both addresses are derivable.
Verified: the new test Pays a claim whose receipt address was pre-funded drops a single lamport on the receipt address before claiming. Against the previous build the claim fails; with the fix it is paid and the receipt ends up owned by the program. 10 tests passing.
I am auditing my other open Pinocchio PRs for the same pattern — the fixed-address PDAs there ([b"counter"], [b"admin-config"], and the per-wallet switch) are derivable too, so they are exposed to the same griefing.
Receipt and airdrop-state addresses are publicly derivable, and CreateAccount refuses to create over an account that already holds lamports. Anyone could therefore send a lamport to a receipt address and block that index's claim permanently. Top the account up, then Allocate and Assign — the same fallback Anchor's init performs.
Adds a Pinocchio implementation of
merkle-tree-token-claimer, alongside the existing Anchor one.What it does
The airdrop pattern: fund a vault once, publish a Merkle root of the balance snapshot, and let each holder claim their allocation by proving membership.
Three instructions:
InitializeAirdropData— creates the mint, mints the whole supply into a vault owned by the airdrop PDA, records the root, then revokes the mint authority so the supply is fixed at what the vault holds.UpdateTree— replaces the root, allowed only before the first claim.ClaimAirdrop— verifies a proof, pays the claimant, and writes a receipt so the index cannot be claimed twice.Proof verification
The leaf is
claimer (32) | amount (8 LE), sha256-hashed; internal nodes aresha256(left || right);indexdoubles as the path, its low bit choosing the side at each level. Two properties are worth calling out because both are load-bearing and neither is obvious:indexmust be zero after the walk. The loop only consumes as many index bits as the proof has levels, so without the trailing check the same proof would also open receipt PDAs atindex + 2^depth,index + 2^(depth+1), … — one leaf, many payouts. Tested with an aliased index.The suite reuses the Anchor version's
tests/merkle.tsverbatim, so both implementations are checked against the same tree builder — including its zero-hash padding for odd levels (duplicating the last node instead would make a parentsha256(C || C), which verifies at two indices). The test tree has three leaves specifically to exercise that padding.Hashing uses
solana-sha256-hasher, which compiles to thesol_sha256syscall on SBF; it is added to the workspace dependencies here.Differences from the Anchor version
[amount, index, hashes…], with the proof length implied by the remaining bytes, rather than a BorshVec<u8>in the middle.init_if_neededcreates it first and then rolls back.One ordering note for reviewers: the already-claimed check runs before proof verification, so replaying a proof at an already claimed index reports
AlreadyClaimedrather thanInvalidProof. The test for proof binding therefore uses an unclaimed index.Tests
9 LiteSVM tests: the full lifecycle through to a drained vault, plus repeat-claim, stolen-proof, aliased-index, non-authority update and post-claim update rejections. Verified locally:
tsc --noEmit,pnpm test,prettier --check,cargo fmt --check,cargo clippy -D warnings.