Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/.workspace-ignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ oracles/pyth/anchor/programs/pythexample
tokens/create-token/anchor/programs/create-token
tokens/escrow/anchor/programs/escrow
tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master
tokens/merkle-tree-token-claimer/anchor/programs/merkle-tree-token-claimer
tokens/nft-operations/anchor/programs/mint-nft
tokens/pda-mint-authority/anchor/programs/token-minter
tokens/token-2022/basics/anchor/programs/basics
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ Create a fundraiser account specifying a target mint and amount, allowing contri

[anchor](./tokens/token-fundraiser/anchor)

### Distributing tokens with Merkle-proof claims

[Fund a vault once, publish a Merkle root of a balance snapshot, and let each holder claim their allocation with a proof](./tokens/merkle-tree-token-claimer/README.md) — the claim pattern behind large airdrops and chain migrations.

[anchor](./tokens/merkle-tree-token-claimer/anchor)

### Minting a token from inside a program with a PDA as the mint authority

[Mint a Token from inside your own onchain program using the Token program.](./tokens/pda-mint-authority/README.md) Reminder: you don't need your own program just to mint an NFT, see the note at the top of this README.
Expand Down
69 changes: 69 additions & 0 deletions tokens/merkle-tree-token-claimer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Merkle Tree Token Claimer

Distribute a snapshot of token balances with one funded vault and a single 32-byte Merkle root, instead of thousands of individual transfers. Each holder claims their own allocation by presenting a Merkle proof, and the program blocks double-claims with per-index claim receipt PDAs.

This is the standard pattern behind large airdrops and chain migrations — for example, retiring a Cosmos app chain and honoring its balances on Solana.

## How it works

1. **Snapshot balances** on the source system at a chosen height, and map each source owner to a Solana address.
2. **Build a fixed Merkle tree** where each leaf is exactly 40 bytes: `[solana_pubkey (32 bytes) | amount (u64 little-endian, 8 bytes)]`.
3. **Initialize the airdrop**: the program stores the Merkle root, mints the full claimable supply into a vault ATA owned by the program PDA, and revokes the mint authority so no further tokens can ever be minted.
4. **Users claim independently** by submitting `amount + merkle_proof + index`.
5. **The program writes a claim receipt PDA** for that index, so the same allocation can never be claimed twice.

```text
User submits: amount + merkle_proof + index
Program recomputes the leaf hash from signer + amount
Program verifies the proof against the on-chain root
Program creates the claim_receipt PDA for that index
Tokens transfer from vault → user's ATA
```

Because the root never changes once claims begin, one user claiming does not invalidate any other user's proof.

## Instructions

| Instruction | Purpose | Who calls |
| ------------------------- | ------------------------------------------------------------- | ---------------- |
| `initialize_airdrop_data` | Create state, mint the supply into the vault, revoke the mint | Authority (once) |
| `update_tree` | Replace the Merkle root, only before any claims have happened | Authority only |
| `claim_airdrop` | Verify the proof, transfer tokens, and create the receipt PDA | Any claimant |

## Building and testing

```bash
cd anchor
pnpm install
anchor test
```

`anchor test` builds the program and runs the LiteSVM test suite in `tests/litesvm.test.ts`, which covers initialization, pre-claim root updates, successful claims with receipts, duplicate-claim rejection, stolen-proof rejection, proof replay under alternate receipt indices, and the post-claim root freeze.

## Generating a tree from a snapshot

`scripts/generate-merkle-tree.ts` turns a snapshot JSON file into the on-chain root plus a proof per claimant:

```bash
cd anchor
pnpm generate-tree scripts/sample-snapshot.json merkle-output.json
```

The tree uses SHA-256 throughout: leaves are `sha256(leaf_bytes)` and parents are `sha256(left || right)`, with the last node of an odd level paired with a 32-byte zero hash. Padding with a zero hash instead of duplicating the last node matters: a duplicated node produces the symmetric parent `sha256(C || C)`, which lets one proof verify under two indices and open two receipt PDAs for the same leaf. `tests/merkle.ts` contains the reference implementation, which matches the program's verifier byte for byte.

## Adapting it for a real distribution

- **Off-chain tooling is on you**: query source balances at the snapshot height, collect each holder's Solana address before the snapshot, and serve each user their proof and index from the generated output.
- **Deploy your own instance**: replace the program ID in `Anchor.toml` and `lib.rs`, and pass your own mint parameters at initialization.
- **Unclaimed balances**: this example keeps claims open forever. If you need a deadline, decay, or clawback policy, add it deliberately — see the migration guide for the tradeoffs.

## Security notes

- The mint authority is revoked during initialization, so the claimable supply is fixed at launch.
- Claim proofs stay stable because `update_tree` refuses to run after the first claim. To change a live distribution, deploy a new instance instead of mutating one users already trust.
- Double-claims are blocked by `claim_receipt` PDAs derived from `(airdrop_state, index)`, and the claim `index` is fully authenticated: verification consumes one index bit per proof level, rejects any leftover high bits, and the zero-hash padding keeps every parent asymmetric — so each leaf verifies under exactly one index and one receipt PDA.
- Claims are bounded twice: each claim checks the proof against the root, and the running `amount_claimed` can never exceed the initialized total.
7 changes: 7 additions & 0 deletions tokens/merkle-tree-token-claimer/anchor/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.anchor
.DS_Store
target
**/*.rs.bk
node_modules
test-ledger
.yarn
4 changes: 4 additions & 0 deletions tokens/merkle-tree-token-claimer/anchor/.mocharc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extension": ["ts"],
"spec": "tests/**/*.ts"
}
7 changes: 7 additions & 0 deletions tokens/merkle-tree-token-claimer/anchor/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.anchor
.DS_Store
target
node_modules
dist
build
test-ledger
19 changes: 19 additions & 0 deletions tokens/merkle-tree-token-claimer/anchor/Anchor.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[toolchain]
anchor_version = "1.0.2"
solana_version = "3.1.8"

[features]
resolution = true
skip-lint = false

[programs.localnet]
merkle_tree_token_claimer = "GTCPuHiGookQVSAgGc7CzBiFYPytjVAq6vdCV3NnZoHa"

[provider]
cluster = "localnet"
wallet = "~/.config/solana/id.json"

[scripts]
test = "pnpm mocha --import=tsx -t 1000000 tests/**/*.test.ts"

[hooks]
15 changes: 15 additions & 0 deletions tokens/merkle-tree-token-claimer/anchor/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[workspace]
members = [
"programs/*"
]
resolver = "2"

[profile.release]
overflow-checks = true
lto = "fat"
codegen-units = 1

[profile.release.build-override]
opt-level = 3
incremental = false
codegen-units = 1
12 changes: 12 additions & 0 deletions tokens/merkle-tree-token-claimer/anchor/migrations/deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Migrations are an early feature. Currently, they're nothing more than this
// single deploy script that's invoked from the CLI, injecting a provider
// configured from the workspace's Anchor.toml.

const anchor = require('@anchor-lang/core');

module.exports = async provider => {
// Configure client to use the provider.
anchor.setProvider(provider);

// Add your deploy script here.
};
33 changes: 33 additions & 0 deletions tokens/merkle-tree-token-claimer/anchor/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"type": "module",
"license": "MIT",
"pnpm": {
"overrides": {
"litesvm": "^0.8.0"
}
},
"scripts": {
"generate-tree": "tsx scripts/generate-merkle-tree.ts",
"lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w",
"lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check"
},
"dependencies": {
"@anchor-lang/core": "1.0.0-rc.5",
"@solana-developers/helpers": "^2.8.1",
"@solana/spl-token": "^0.4.14",
"@solana/web3.js": "^1.98.4"
},
"devDependencies": {
"@types/bn.js": "^5.2.0",
"@types/chai": "^5.2.3",
"@types/mocha": "^10.0.10",
"@types/node": "^26.1.0",
"anchor-litesvm": "^0.2.1",
"chai": "^6.2.2",
"litesvm": "^0.8.0",
"mocha": "^11.7.5",
"prettier": "^3.7.4",
"tsx": "^4.19.2",
"typescript": "^5.9.3"
}
}
Loading
Loading