Skip to content

feat(merkle-tree-token-claimer): add pinocchio example - #714

Open
MarkFeder wants to merge 2 commits into
solana-foundation:mainfrom
MarkFeder:tokens-merkle-tree-token-claimer-pinocchio
Open

feat(merkle-tree-token-claimer): add pinocchio example#714
MarkFeder wants to merge 2 commits into
solana-foundation:mainfrom
MarkFeder:tokens-merkle-tree-token-claimer-pinocchio

Conversation

@MarkFeder

Copy link
Copy Markdown
Contributor

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 are sha256(left || right); index doubles 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:

  • The leaf commits to the claimer, so a proof lifted from someone else's claim recomputes a different root. Tested.
  • index must 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 at index + 2^depth, index + 2^(depth+1), … — one leaf, many payouts. Tested with an aliased index.

The suite reuses the Anchor version's tests/merkle.ts verbatim, 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 parent sha256(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 the sol_sha256 syscall on SBF; it is added to the workspace dependencies here.

Differences from the Anchor version

  • Instruction data puts the proof last: [amount, index, hashes…], with the proof length implied by the remaining bytes, rather than a Borsh Vec<u8> in the middle.
  • No 8-byte account discriminators, so the state is 113 bytes and each receipt 81.
  • The receipt account is created only after the proof verifies, so a bad proof costs the caller nothing and leaves no account behind. Anchor's init_if_needed creates 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 AlreadyClaimed rather than InvalidProof. 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.

@MarkFeder
MarkFeder requested a review from dev-jodee as a code owner August 31, 2026 12:34
@greptile-apps

greptile-apps Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a Pinocchio implementation of the Merkle-tree token claimer, including initialization, root updates, proof-backed claims, receipt tracking, and LiteSVM coverage.

  • Adds the Pinocchio on-chain program and workspace dependencies.
  • Adds matching TypeScript clients and lifecycle/security regression tests.
  • Reworks PDA creation to tolerate publicly derivable addresses that were pre-funded before initialization.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
tokens/merkle-tree-token-claimer/pinocchio/program/src/instructions/claim_airdrop.rs Implements proof validation, receipt replay protection, token payout, and the corrected pre-funded receipt creation path.
tokens/merkle-tree-token-claimer/pinocchio/program/src/util.rs Adds a shared PDA creation helper that safely tops up, allocates, and assigns pre-funded system-owned PDA addresses.
tokens/merkle-tree-token-claimer/pinocchio/tests/test.ts Covers the complete airdrop lifecycle and directly verifies that a pre-funded receipt PDA no longer blocks a valid claim.
tokens/merkle-tree-token-claimer/pinocchio/program/src/merkle.rs Implements Merkle root reconstruction using claimant-bound leaves, ordered sibling hashing, and full index consumption.
tokens/merkle-tree-token-claimer/pinocchio/program/src/instructions/initialize_airdrop_data.rs Initializes the airdrop state, mint, and vault while using the shared pre-funding-tolerant PDA creation helper.

Reviews (2): Last reviewed commit: "merkle-tree-token-claimer: create PDAs o..." | Re-trigger Greptile

Comment on lines +128 to +135
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)])?;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant