From 30ead7afb78ba2ffef700f2d4fe1ade157b4ed91 Mon Sep 17 00:00:00 2001 From: L0STE <125566964+L0STE@users.noreply.github.com> Date: Mon, 13 Jan 2025 17:56:19 +0100 Subject: [PATCH 1/5] First Commit, still in draft --- .../products/tokenMetadata/index.js | 4 + .../anchor/token-claimer-smart-contract.md | 958 ++++++++++++++++++ src/pages/token-metadata/guides/index.md | 2 + 3 files changed, 964 insertions(+) create mode 100644 src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md diff --git a/src/components/products/tokenMetadata/index.js b/src/components/products/tokenMetadata/index.js index 794b04ca..3cff06d8 100644 --- a/src/components/products/tokenMetadata/index.js +++ b/src/components/products/tokenMetadata/index.js @@ -85,6 +85,10 @@ export const tokenMetadata = { title: 'Account Size Reduction', href: '/token-metadata/guides/account-size-reduction', }, + { + title: 'Token Claimer Smart Contract', + href: '/token-metadata/guides/anchor/token-claimer-smart-contract', + }, ], }, { diff --git a/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md b/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md new file mode 100644 index 00000000..8f696687 --- /dev/null +++ b/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md @@ -0,0 +1,958 @@ +--- +title: How to Create a Token Claimer Smart Contract leveraging Merkle Tree +metaTitle: How to Create a Token Claimer Smart Contract | Token Metadata Guides +description: Learn how to create a Token Claimer Smart Contract on Solana leveraging Merkle Trees and using Anchor! +# remember to update dates also in /components/guides/index.js +created: '01-13-2025' +updated: '01-13-2025' +--- + +This guide leverages the use of **Merkle Trees** and **Compression** to create a low-cost **Token Claimer** for **Token Metadata Tokens** using **Anchor**. + +## Prerequisite + +Before starting to learn more about how the Token Claimer works, we should look into how Compression and Merkle Trees work to really understand what is going on inside of the smart contract. + +### Merkle Trees + +A Merkle tree is a binary tree used to efficiently represent a set of data. Each leaf node in the tree is a hash of individual data (e.g., an address and token amount). Parent nodes are created by hashing pairs of child nodes, continuing up the tree until reaching the root, which serves as a compact and tamper-proof representation of the entire dataset. + +**Example**: Suppose we have four data entries: A, B, C, and D. The Merkle tree structure is built as follows: + +- **Leaf Nodes**: Each entry is hashed: +``` +Hash(A), Hash(B), Hash(C), Hash(D) +``` + +- **Parent Nodes**: Pairs of leaf nodes are combined and hashed: +``` +Parent1 = Hash(Hash(A) + Hash(B)) +Parent2 = Hash(Hash(C) + Hash(D)) +``` + +- **Root Node**: The final hash is computed from the parent nodes: +``` +Root = Hash(Parent1 + Parent2) +``` + +Merkle trees form the foundation of on-chain compression. Since altering any part of the dataset invalidates the root, it is possible to prove the validity of a specific entry by storing only the root onchain and providing a Merkle Proof (a minimal set of sibling hashes needed to recalculate the root). + +This makes it extremely cost-efficient for on-chain storage, as only the root (32 bytes) is stored, and proofs are passed as inputs during verification. Additionally, proof sizes grow logarithmically with the number of entries, making this approach ideal for large datasets. + +Moreover, Merkle trees can be generated and stored in a private manner, without broadcasting the full dataset on-chain (like Compressed NFT do with the noop program), further enhancing efficiency and privacy. + +### Concurrent Merkle Tree + +Solana's state compression employs a unique type of Merkle tree that enables multiple changes to a tree while preserving its integrity and validity. + +This specialized tree, called a concurrent Merkle tree, maintains an on-chain changelog, allowing multiple rapid updates to the same tree (e.g., all within the same block) without invalidating proofs. + +This functionality is crucial because, on Solana, only one writer per block is allowed per account, meaning only a single change can be made to an account per block, while multiple readers are permitted. The runtime ensures that the account remains secure and cannot be corrupted. However, since every action involves writing—because the Merkle root must be re-uploaded—the concurrent Merkle tree provides a solution for handling multiple updates seamlessly within the same block. + +## Setup + +- Code Editor of your choice (recommended **Visual Studio Code** with the **Rust Analyzer Plugin**) +- Anchor **0.30.1** or above. + +Additionally, in this guide we’re going to leverage a mono-file approach to **Anchor** where all the necessary macros can be found in the `lib.rs` file: +- `declare_id`: Specifies the program's on-chain address. +- `#[program]`: Specifies the module containing the program’s instruction logic. +- `#[derive(Accounts)]`: Applied to structs to indicate a list of accounts required for an instruction. +- `#[account]`: Applied to structs to create custom account types specific to the program. + +**Note**: You may need to modify and move functions around to suit your needs. + +### Initializing the Program + +Start by initializing a new project (optional) using `avm` (Anchor Version Manager). To initialize it, run the following command in your terminal + +``` +anchor init token-claimer-example +``` + +### Required Crates + +In this guide, we'll use the `svm_merkle_tree` crate an optimized version for creating and managing merkle trees for the SVM. To install it, first navigate to the `token-claimer-example` directory: + +``` +cd token-claimer-example +``` + +Then run the following command: + +``` +cargo add svm_merkle_tree +``` + +## The program + +### todo()! - ADD Disclaimer. + +### Imports and Templates + +Here we're going to define all the imports for this particular guide and create the template for the Account struct and instruction in our `lib.rs` file. + +```rust +use anchor_lang::prelude::*; + +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token::{mint_to, set_authority, transfer, Mint, MintTo, SetAuthority, Token, TokenAccount, Transfer, spl_token::instruction::AuthorityType} + +use svm_merkle_tree::{HashingAlgorithm, MerkleProof}; + +declare_id!("C9PLf3qMCVqtUCJtEBy8NCcseNp3KTZwFJxAtDdN1bto"); + +/// Instructions and Logic behind the program +#[program] +pub mod merkle_tree_token_claimer { + use super::*; + + pub fn initialize_airdrop_data( + ctx: Context, + merkle_root: [u8; 32], + amount: u64, + ) -> Result<()> { + + Ok(()) + } + + pub fn update_tree( + ctx: Context, + new_root: [u8; 32] + ) -> Result<()> { + + Ok(()) + } + + pub fn claim_airdrop( + ctx: Context, + amount: u64, + hashes: Vec, + index: u64, + ) -> Result<()> { + + Ok(()) + } + +} + +/// Account Struct for the different Instructions +#[derive(Accounts)] +pub struct Initialize<'info> { + +} + +#[derive(Accounts)] +pub struct Update<'info> { + +} + +#[derive(Accounts)] +pub struct Claim<'info> { + +} + +/// State account holding the merkle tree and airdrop information +#[account] +pub struct AirdropState { + +} + +/// Error for the Program +#[error_code] +pub enum AirdropError { + +} +``` + +This serves as the template for the on-chain program, however, there is also significant frontend overhead to consider which we’ll address in detail in this writeup. + +### Initializing the Merkle Tree + +We begin by initializing a Merkle tree with user data, including their claimable amounts. This process is performed off-chain, where we calculate the tree's root and later upload it on-chain, reducing computational costs while maintaining integrity. + +In this example, we generate 100 random addresses and 100 random amounts, and initialize an `isClaimed` flag for each entry, setting it to false. These details are serialized into binary format to efficiently populate the Merkle tree and we then merklize the data we just created to create the root. + +```typescript +import * as anchor from "@coral-xyz/anchor"; +import { Keypair, PublicKey, SystemProgram, LAMPORTS_PER_SOL, Transaction } from "@solana/web3.js"; +import { HashingAlgorithm, MerkleTree } from "svm-merkle-tree"; + +// Generate 100 random addresses and amount +let merkleTreeData = Array.from({ length: 100 }, () => ({ + address: Keypair.generate().publicKey, // Example random address + amount: Math.floor(Math.random() * 1000), // Example random amount + isClaimed: false, // Default value for isClaimed +})); + +// Create Merkle Tree +let merkleTree = new MerkleTree(HashingAlgorithm.Keccak, 32); + +merkleTreeData.forEach((entry) => { + // Serialize address, amount, and isClaimed in binary format + const entryBytes = Buffer.concat([ + entry.address.toBuffer(), + Buffer.from(new Uint8Array(new anchor.BN(entry.amount).toArray('le', 8))), + Buffer.from([entry.isClaimed ? 1 : 0]), + ]); + merkleTree.add_leaf(entryBytes); +}); + +merkleTree.merklize(); + +const merkleRoot = Array.from(merkleTree.get_merkle_root()); +``` + +On-chain, we define an `AirdropState` account to manage and track the state of the airdrop. This account holds key information needed to securely distribute tokens based on the Merkle tree mechanism. Below is the breakdown of each field: + +```rust +#[account] +pub struct AirdropState { + /// The current merkle root + pub merkle_root: [u8; 32], + /// The authority who can update the merkle root + pub authority: Pubkey, + /// The mint address of the token being airdropped + pub mint: Pubkey, + /// Total amount allocated for the airdrop + pub airdrop_amount: u64, + /// Total amount claimed so far + pub amount_claimed: u64, + /// PDA bump seed + pub bump: u8, +} +``` + +The `initialize_airdrop_data` instruction will just populate the `AirdropState` and, before revoking the `mint_authority`, mint enough tokens in the `vault` + +```rust +pub fn initialize_airdrop_data( + ctx: Context, + merkle_root: [u8; 32], + amount: u64, +) -> Result<()> { + + ctx.accounts.airdrop_state.set_inner( + AirdropState { + merkle_root, + authority: ctx.accounts.authority.key(), + mint: ctx.accounts.mint.key(), + airdrop_amount: amount, + amount_claimed: 0, + bump: ctx.bumps.airdrop_state, + } + ); + + mint_to( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + MintTo { + mint: ctx.accounts.mint.to_account_info(), + to: ctx.accounts.vault.to_account_info(), + authority: ctx.accounts.authority.to_account_info(), + } + ), + amount + )?; + + set_authority( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + SetAuthority { + current_authority: ctx.accounts.authority.to_account_info(), + account_or_mint: ctx.accounts.mint.to_account_info(), + } + ), + AuthorityType::MintTokens, + None + )?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct Initialize<'info> { + #[account( + init, + seeds = [b"merkle_tree".as_ref(), mint.key().to_bytes().as_ref()], + bump, + payer = authority, + space = 8 + 32 + 32 + 32 + 8 + 8 + 1 + )] + pub airdrop_state: Account<'info, AirdropState>, + #[account(mut)] + pub mint: Account<'info, Mint>, + #[account( + init_if_needed, + payer = authority, + associated_token::mint = mint, + associated_token::authority = airdrop_state, + )] + pub vault: Account<'info, TokenAccount>, + #[account(mut)] + pub authority: Signer<'info>, + pub system_program: Program<'info, System>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, +} +``` + +### Update the Merkle Root onchain if needed + +We're going to create the `update_tree` instruction that will let the `authority` of the `AirdropState` change the root onchain. This can be used to add a new user or to revoke allocation. + +For this example we're going to create a new random allocation for a random address and push this entry in the Merkle Tree we created in the last instruction, like this: + +```typescript +const newData = { + address: Keypair.generate().publicKey, + amount: Math.floor(Math.random() * 1000), // Example random amount + isClaimed: false, // Default value for isClaimed +}; + +merkleTreeData.push(newData); + +const entryBytes = Buffer.concat([ + newData.address.toBuffer(), // PublicKey as bytes + Buffer.from(new Uint8Array(new anchor.BN(newData.amount).toArray('le', 8))), // Amount as little-endian + Buffer.from([newData.isClaimed ? 1 : 0]), // isClaimed as 1 byte +]); + +merkleTree.add_leaf(entryBytes); + +merkleTree.merklize(); + +const newMerkleRoot = Array.from(merkleTree.get_merkle_root()); +``` + +We can easily update the root this way then: + +```rust +pub fn update_tree( + ctx: Context, + new_root: [u8; 32] +) -> Result<()> { + + ctx.accounts.airdrop_state.merkle_root = new_root; + + Ok(()) +} + +#[derive(Accounts)] +pub struct Update<'info> { + #[account( + mut, + has_one = authority, + seeds = [b"merkle_tree".as_ref(), airdrop_state.mint.key().to_bytes().as_ref()], + bump = airdrop_state.bump + )] + pub airdrop_state: Account<'info, AirdropState>, + pub authority: Signer<'info>, +} +``` + +We verify that this change is "safe" because we check the authority provided against the authority saved in the `AirdropState` using the `has_one` constrain. + +### Claiming instruction for the User + +When a user claims tokens, their eligibility is verified using the Merkle Tree Root stored on-chain. + +**Step 1: Generate Merkle Proof**: + +The system locates the user’s data in the external Merkle Tree database, generated from the previous examples, and generates the Merkle Proof. This proof includes the hashes of sibling nodes along the path to the user’s leaf and the index, enabling the verification process, like this: + +```typescript +const index = merkleTreeData.findIndex(data => data.address.equals(newAddress.publicKey)); + +if (index === -1) { + throw new Error("Address not found in Merkle tree data"); +} + +const proof = merkleTree.merkle_proof_index(index); +const proofArray = Buffer.from(proof.get_pairing_hashes()); +``` + +**Step 2: On-Chain Verification** + +Using the user’s submitted data, the generated Merkle Proof, and the data’s index, the system reconstructs the Merkle Root on-chain. The reconstructed root is then compared to the stored root to ensure the claim is valid and the Merkle Tree's integrity is preserved. + +```rust +pub fn claim_airdrop( + ctx: Context, + amount: u64, + hashes: Vec, + index: u64, +) -> Result<()> { + let airdrop_state = &mut ctx.accounts.airdrop_state; + + // Step 1: Verify that the Signer and Amount are right by computing the original leaf + let mut original_leaf = Vec::new(); + original_leaf.extend_from_slice(&ctx.accounts.signer.key().to_bytes()); + original_leaf.extend_from_slice(&amount.to_le_bytes()); + original_leaf.push(0u8); // isClaimed = false + + // Step 2: Verify the Merkle proof against the on-chain root + let merkle_proof = MerkleProof::new( + HashingAlgorithm::Keccak, + 32, + index as u32, + hashes.clone(), + ); + + let computed_root = merkle_proof + .merklize(&original_leaf) + .map_err(|_| AirdropError::InvalidProof)?; + + require!( + computed_root.eq(&airdrop_state.merkle_root), + AirdropError::InvalidProof + ); + + // Step 3: Execute the transfer + let mint_key = ctx.accounts.mint.key().to_bytes(); + + let signer_seeds = &[ + b"merkle_tree".as_ref(), + mint_key.as_ref(), + &[airdrop_state.bump], + ]; + + transfer( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.vault.to_account_info(), + to: ctx.accounts.signer_ata.to_account_info(), + authority: airdrop_state.to_account_info(), + }, + &[signer_seeds], + ), + amount, + )?; + + Ok(()) +} +``` + +To ensure accuracy, the root is reconstructed from the input provided in the accounts struct and compared against the stored root on-chain to verify they match. + +**Step 3: Claiming** + +For simplicity in this example, we won’t implement concurrency. Instead, we present two possible approaches to handle claims: + +- Set the isClaimed flag to true and recompute the Merkle Root on-chain. The main drawback of this approach is that the account will be locked during the update, as explained in the[ Concurrent Merkle Tree section](#concurrent-merkle-tree). This limits claims to one user per block and can be implemented in the same instruction like this after the transfer: + +{% totem %} + +{% totem-accordion title="Code Example" %} + +```rust +// Step 4: Update the `is_claimed` flag in the leaf +let mut updated_leaf = Vec::new(); +updated_leaf.extend_from_slice(&ctx.accounts.signer.key().to_bytes()); +updated_leaf.extend_from_slice(&amount.to_le_bytes()); +updated_leaf.push(1u8); // isClaimed = true + +let updated_root: [u8; 32] = merkle_proof + .merklize(&updated_leaf) + .map_err(|_| AirdropError::InvalidProof)? + .try_into() + .map_err(|_| AirdropError::InvalidProof)?; + +// Step 5: Update the Merkle root in the airdrop state +airdrop_state.merkle_root = updated_root; + +// Step 6: Update the airdrop state +airdrop_state.amount_claimed = airdrop_state + .amount_claimed + .checked_add(amount) + .ok_or(AirdropError::OverFlow)?; +``` + +{% /totem-accordion %} + +{% /totem %} + +- Create a Program Derived Address (PDA) for each user after they claim their tokens. This method avoids locking the `AirdropState` account, but it requires users to pay rent for the new PDA. This can be implemented in the same instruction, we will just need to change the Account struct like this: + +{% totem %} + +{% totem-accordion title="Code Example" %} + +```rust +#[derive(Accounts)] +pub struct Claim<'info> { + //... + #[account( + init, + payer = signer, + seeds = [b"user_receipt".as_ref(), signer.key().to_bytes().as_ref()], + bump, + space = 8 + 32 + )] + pub user_receipt: Account<'info, UserReceipt>, + //... +} + +#[account] +pub struct UserReceipt { + pub user: Pubkey, +} +``` + +{% /totem-accordion %} + +{% /totem %} + +**Note**: The `user_receipt` account could be left empty to minimize rent costs. However, this makes it harder to check on the frontend if a user has already claimed their tokens. This tradeoff should be considered based on the specific requirements of the program. + +## Full Example Code + +Here is the complete example of the Smart Contract for updating the root on-chain after a claim: + +{% totem %} + +{% totem-accordion title="Smart Contract Code Example" %} + +```rust +use anchor_lang::prelude::*; +use anchor_spl::{associated_token::AssociatedToken, token::{mint_to, set_authority, transfer, Mint, MintTo, SetAuthority, Token, TokenAccount, Transfer, spl_token::instruction::AuthorityType}}; +use svm_merkle_tree::{HashingAlgorithm, MerkleProof}; + +declare_id!("GTCPuHiGookQVSAgGc7CzBiFYPytjVAq6vdCV3NnZoHa"); + +#[program] +pub mod merkle_tree_token_claimer { + use super::*; + + pub fn initialize_airdrop_data( + ctx: Context, + merkle_root: [u8; 32], + amount: u64, + ) -> Result<()> { + + ctx.accounts.airdrop_state.set_inner( + AirdropState { + merkle_root, + authority: ctx.accounts.authority.key(), + mint: ctx.accounts.mint.key(), + airdrop_amount: amount, + amount_claimed: 0, + bump: ctx.bumps.airdrop_state, + } + ); + + mint_to( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + MintTo { + mint: ctx.accounts.mint.to_account_info(), + to: ctx.accounts.vault.to_account_info(), + authority: ctx.accounts.authority.to_account_info(), + } + ), + amount + )?; + + set_authority( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + SetAuthority { + current_authority: ctx.accounts.authority.to_account_info(), + account_or_mint: ctx.accounts.mint.to_account_info(), + } + ), + AuthorityType::MintTokens, + None + )?; + + Ok(()) + } + + pub fn update_tree( + ctx: Context, + new_root: [u8; 32] + ) -> Result<()> { + + ctx.accounts.airdrop_state.merkle_root = new_root; + + Ok(()) + } + + pub fn claim_airdrop( + ctx: Context, + amount: u64, + hashes: Vec, + index: u64, + ) -> Result<()> { + let airdrop_state = &mut ctx.accounts.airdrop_state; + + // Step 1: Verify that the Signer and Amount are right by computing the original leaf + let mut original_leaf = Vec::new(); + original_leaf.extend_from_slice(&ctx.accounts.signer.key().to_bytes()); + original_leaf.extend_from_slice(&amount.to_le_bytes()); + original_leaf.push(0u8); // isClaimed = false + + // Step 2: Verify the Merkle proof against the on-chain root + let merkle_proof = MerkleProof::new( + HashingAlgorithm::Keccak, + 32, + index as u32, + hashes.clone(), + ); + + let computed_root = merkle_proof + .merklize(&original_leaf) + .map_err(|_| AirdropError::InvalidProof)?; + + require!( + computed_root.eq(&airdrop_state.merkle_root), + AirdropError::InvalidProof + ); + + // Step 3: Execute the transfer + let mint_key = ctx.accounts.mint.key().to_bytes(); + let signer_seeds = &[ + b"merkle_tree".as_ref(), + mint_key.as_ref(), + &[airdrop_state.bump], + ]; + + transfer( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.vault.to_account_info(), + to: ctx.accounts.signer_ata.to_account_info(), + authority: airdrop_state.to_account_info(), + }, + &[signer_seeds], + ), + amount, + )?; + + // Step 4: Update the `is_claimed` flag in the leaf + let mut updated_leaf = Vec::new(); + updated_leaf.extend_from_slice(&ctx.accounts.signer.key().to_bytes()); + updated_leaf.extend_from_slice(&amount.to_le_bytes()); + updated_leaf.push(1u8); // isClaimed = true + + let updated_root: [u8; 32] = merkle_proof + .merklize(&updated_leaf) + .map_err(|_| AirdropError::InvalidProof)? + .try_into() + .map_err(|_| AirdropError::InvalidProof)?; + + // Step 5: Update the Merkle root in the airdrop state + airdrop_state.merkle_root = updated_root; + + // Step 6: Update the airdrop state + airdrop_state.amount_claimed = airdrop_state + .amount_claimed + .checked_add(amount) + .ok_or(AirdropError::OverFlow)?; + + Ok(()) + } +} + +#[derive(Accounts)] +pub struct Initialize<'info> { + #[account( + init, + seeds = [b"merkle_tree".as_ref(), mint.key().to_bytes().as_ref()], + bump, + payer = authority, + space = 8 + 32 + 32 + 32 + 8 + 8 + 1 + )] + pub airdrop_state: Account<'info, AirdropState>, + #[account(mut)] + pub mint: Account<'info, Mint>, + #[account( + init_if_needed, + payer = authority, + associated_token::mint = mint, + associated_token::authority = airdrop_state, + )] + pub vault: Account<'info, TokenAccount>, + #[account(mut)] + pub authority: Signer<'info>, + pub system_program: Program<'info, System>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, +} + +#[derive(Accounts)] +pub struct Update<'info> { + #[account( + mut, + has_one = authority, + seeds = [b"merkle_tree".as_ref(), airdrop_state.mint.key().to_bytes().as_ref()], + bump = airdrop_state.bump + )] + pub airdrop_state: Account<'info, AirdropState>, + pub authority: Signer<'info>, +} + +#[derive(Accounts)] +pub struct Claim<'info> { + #[account( + mut, + has_one = mint, + seeds = [b"merkle_tree".as_ref(), mint.key().to_bytes().as_ref()], + bump = airdrop_state.bump + )] + pub airdrop_state: Account<'info, AirdropState>, + pub mint: Account<'info, Mint>, + #[account( + mut, + associated_token::mint = mint, + associated_token::authority = airdrop_state, + )] + pub vault: Account<'info, TokenAccount>, + #[account( + init_if_needed, + payer = signer, + associated_token::mint = mint, + associated_token::authority = signer, + )] + pub signer_ata: Account<'info, TokenAccount>, + #[account(mut)] + pub signer: Signer<'info>, + pub system_program: Program<'info, System>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, +} + +/// State account holding the merkle tree and airdrop information +#[account] +pub struct AirdropState { + /// The current merkle root + pub merkle_root: [u8; 32], + /// The authority who can update the merkle root + pub authority: Pubkey, + /// The mint address of the token being airdropped + pub mint: Pubkey, + /// Total amount allocated for the airdrop + pub airdrop_amount: u64, + /// Total amount claimed so far + pub amount_claimed: u64, + /// PDA bump seed + pub bump: u8, +} + +#[error_code] +pub enum AirdropError { + #[msg("Invalid Merkle proof")] + InvalidProof, + #[msg("Already claimed")] + AlreadyClaimed, + #[msg("Amount overflow")] + OverFlow, +} +``` + +{% /totem-accordion %} + +{% /totem %} + +And here is the corresponding test.ts file with code to implement and test the Merkle tree: + +{% totem %} + +{% totem-accordion title="Typescript Testing Code Example" %} + +```typescript +import * as anchor from "@coral-xyz/anchor"; +import { Program } from "@coral-xyz/anchor"; +import { MerkleTreeTokenClaimer } from "../target/types/merkle_tree_token_claimer"; +import { expect } from "chai"; +import { Keypair, PublicKey, SystemProgram, LAMPORTS_PER_SOL, Transaction } from "@solana/web3.js"; +import { getAssociatedTokenAddress, TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { HashingAlgorithm, MerkleTree } from "svm-merkle-tree"; +import { ASSOCIATED_PROGRAM_ID } from "@coral-xyz/anchor/dist/cjs/utils/token"; + + +describe("merkle-tree-token-claimer", () => { + const provider = anchor.AnchorProvider.env(); + anchor.setProvider(provider); + const wallet = anchor.Wallet.local(); + + Keypair.fromSecretKey + const program = anchor.workspace.MerkleTreeTokenClaimer as Program; + + let authority = wallet.payer; + let mint = Keypair.generate(); + let newAddress: Keypair; + let airdropState: PublicKey; + let merkleTree: MerkleTree; + let vault: PublicKey; + let newData: AirdropTokenData; + + interface AirdropTokenData { + address: PublicKey; + amount: number; + isClaimed: boolean; + } + let merkleTreeData: AirdropTokenData[]; + + before(async () => { + airdropState = PublicKey.findProgramAddressSync([Buffer.from("merkle_tree"), mint.publicKey.toBuffer()], program.programId)[0]; + vault = await getAssociatedTokenAddress(mint.publicKey, airdropState, true); + + // Airdrop SOL to authority + await provider.sendAndConfirm( + new Transaction().add( + SystemProgram.transfer({ + fromPubkey: provider.publicKey, + toPubkey: authority.publicKey, + lamports: 10 * LAMPORTS_PER_SOL, + }) + ), + [] + ); + + // Generate 100 random addresses and amount + merkleTreeData = Array.from({ length: 100 }, () => ({ + address: Keypair.generate().publicKey, + amount: Math.floor(Math.random() * 1000), // Example random amount + isClaimed: false, // Default value for isClaimed + })); + + // Create Merkle Tree + merkleTree = new MerkleTree(HashingAlgorithm.Keccak, 32); + merkleTreeData.forEach((entry) => { + // Serialize address, amount, and isClaimed in binary format + const entryBytes = Buffer.concat([ + entry.address.toBuffer(), + Buffer.from(new Uint8Array(new anchor.BN(entry.amount).toArray('le', 8))), + Buffer.from([entry.isClaimed ? 1 : 0]), + ]); + merkleTree.add_leaf(entryBytes); + }); + merkleTree.merklize(); + + }); + + it("Initialize airdrop data", async () => { + const merkleRoot = Array.from(merkleTree.get_merkle_root()); + const totalAirdropAmount = merkleTreeData.reduce((sum, entry) => sum + entry.amount, 0); + + await program.methods.initializeAirdropData(merkleRoot, new anchor.BN(totalAirdropAmount)) + .accountsPartial({ + airdropState, + mint: mint.publicKey, + vault, + authority: authority.publicKey, + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_PROGRAM_ID, + }) + .signers([authority, mint]) + .rpc(); + + const account = await program.account.airdropState.fetch(airdropState); + expect(account.merkleRoot).to.deep.equal(merkleRoot); + expect(account.authority.toString()).to.equal(authority.publicKey.toString()); + }); + + it("Update root", async () => { + const newData = { + address: Keypair.generate().publicKey, + amount: Math.floor(Math.random() * 1000), // Example random amount + isClaimed: false, // Default value for isClaimed + }; + merkleTreeData.push(newData); + const entryBytes = Buffer.concat([ + newData.address.toBuffer(), // PublicKey as bytes + Buffer.from(new Uint8Array(new anchor.BN(newData.amount).toArray('le', 8))), // Amount as little-endian + Buffer.from([newData.isClaimed ? 1 : 0]), // isClaimed as 1 byte + ]); + merkleTree.add_leaf(entryBytes); + merkleTree.merklize(); + + const newMerkleRoot = Array.from(merkleTree.get_merkle_root()); + + await program.methods.updateTree(newMerkleRoot) + .accountsPartial({ + airdropState: airdropState, + authority: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + const account = await program.account.airdropState.fetch(airdropState); + expect(account.merkleRoot).to.deep.equal(newMerkleRoot); + }); + + it("Perform claim with whitelisted address", async () => { + newAddress = Keypair.generate(); + newData = { + address: newAddress.publicKey, + amount: Math.floor(Math.random() * 1000), // Example random amount + isClaimed: false, // Default value for isClaimed + } + merkleTreeData.push(newData); + const entryBytes = Buffer.concat([ + newData.address.toBuffer(), // PublicKey as bytes + Buffer.from(new Uint8Array(new anchor.BN(newData.amount).toArray('le', 8))), // Amount as little-endian + Buffer.from([newData.isClaimed ? 1 : 0]), // isClaimed as 1 byte + ]); + merkleTree.add_leaf(entryBytes); + merkleTree.merklize(); + + const newMerkleRoot = Array.from(merkleTree.get_merkle_root()); + + await program.methods.updateTree(newMerkleRoot) + .accountsPartial({ + airdropState: airdropState, + authority: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + const index = merkleTreeData.findIndex(data => data.address.equals(newAddress.publicKey)); + if (index === -1) { + throw new Error("Address not found in Merkle tree data"); + } + + const proof = merkleTree.merkle_proof_index(index); + const proofArray = Buffer.from(proof.get_pairing_hashes()); + + await provider.sendAndConfirm( + new Transaction().add( + SystemProgram.transfer({ + fromPubkey: provider.publicKey, + toPubkey: newAddress.publicKey, + lamports: 10 * LAMPORTS_PER_SOL, + }) + ), + [] + ); + + try { + await program.methods.claimAirdrop(new anchor.BN(newData.amount), proofArray, new anchor.BN(index)) + .accountsPartial({ + airdropState, + mint: mint.publicKey, + vault, + signerAta: await getAssociatedTokenAddress(mint.publicKey, newAddress.publicKey), + signer: newAddress.publicKey, + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_PROGRAM_ID, + }) + .signers([newAddress]) + .rpc(); + console.log("Action performed successfully for whitelisted address"); + } catch (error) { + console.error("Error performing action:", error); + throw error; + } + }); +}; +``` + +{% /totem-accordion %} + +{% /totem %} \ No newline at end of file diff --git a/src/pages/token-metadata/guides/index.md b/src/pages/token-metadata/guides/index.md index 1d957724..1a517689 100644 --- a/src/pages/token-metadata/guides/index.md +++ b/src/pages/token-metadata/guides/index.md @@ -14,4 +14,6 @@ The following guides for MPL Token Metadata are currently available: {% quick-link title="Account Size Reduction" icon="Lightbulb" href="/token-metadata/guides/account-size-reduction" description="Learn more about the TM Account Size Reduction" /%} +{% quick-link title="Token Claimer Smart Contract" icon="CodeBracketSquare" href="/token-metadata/guides/anchor/token-claimer-smart-contract" description="Learn how to create a Token Claimer Smart Contract on Solana leveraging Merkle Trees and using Anchor!" /%} + {% /quick-links %} \ No newline at end of file From 9734c95eee08e8b154ceac15bbfd484a11fb8363 Mon Sep 17 00:00:00 2001 From: L0STE <125566964+L0STE@users.noreply.github.com> Date: Mon, 13 Jan 2025 18:55:13 +0100 Subject: [PATCH 2/5] added callout --- .../anchor/token-claimer-smart-contract.md | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md b/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md index 8f696687..11b6a559 100644 --- a/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md +++ b/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md @@ -7,7 +7,7 @@ created: '01-13-2025' updated: '01-13-2025' --- -This guide leverages the use of **Merkle Trees** and **Compression** to create a low-cost **Token Claimer** for **Token Metadata Tokens** using **Anchor**. +This guide leverages the use of Merkle Trees and Compression to create a low-cost Token Claimer for Token Metadata Tokens using Anchor. ## Prerequisite @@ -35,11 +35,11 @@ Parent2 = Hash(Hash(C) + Hash(D)) Root = Hash(Parent1 + Parent2) ``` -Merkle trees form the foundation of on-chain compression. Since altering any part of the dataset invalidates the root, it is possible to prove the validity of a specific entry by storing only the root onchain and providing a Merkle Proof (a minimal set of sibling hashes needed to recalculate the root). +Merkle trees are a cornerstone of on-chain compression, enabling efficient and secure data verification. By design, altering any part of the dataset invalidates the root, which means the integrity of a specific entry can be verified by storing only the Merkle root on-chain and providing a Merkle Proof—a minimal set of sibling hashes needed to reconstruct the root. -This makes it extremely cost-efficient for on-chain storage, as only the root (32 bytes) is stored, and proofs are passed as inputs during verification. Additionally, proof sizes grow logarithmically with the number of entries, making this approach ideal for large datasets. - -Moreover, Merkle trees can be generated and stored in a private manner, without broadcasting the full dataset on-chain (like Compressed NFT do with the noop program), further enhancing efficiency and privacy. +- **Key Advantages**: Cost-Efficient Storage: Only the Merkle root (32 bytes) is stored on-chain, significantly reducing storage costs. Verification is achieved by passing Merkle proofs as inputs. +- **Scalability**: Proof sizes grow logarithmically with the number of entries, making this method ideal for managing large datasets. +- **Privacy and Efficiency**: Entire Merkle trees can be generated and managed off-chain, keeping the full dataset private. Programs like Compressed NFTs use this approach with Solana’s noop program, optimizing performance while maintaining privacy. ### Concurrent Merkle Tree @@ -78,15 +78,25 @@ In this guide, we'll use the `svm_merkle_tree` crate an optimized version for cr cd token-claimer-example ``` -Then run the following command: +Then run the following command to install the merkle tree crate: ``` cargo add svm_merkle_tree ``` +And then we run the following command to install the anchor-spl to interact with the Token Program: + +``` +cargo add anchor-spl +``` + ## The program -### todo()! - ADD Disclaimer. +{% callout title = "Disclaimer" %} + +todo()! + +{% /callout %} ### Imports and Templates From a7df51504092f263db12f9abf1665e8bc10c7097 Mon Sep 17 00:00:00 2001 From: L0STE <125566964+L0STE@users.noreply.github.com> Date: Mon, 13 Jan 2025 18:58:14 +0100 Subject: [PATCH 3/5] Fixed some issues --- .../guides/anchor/token-claimer-smart-contract.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md b/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md index 11b6a559..73054eda 100644 --- a/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md +++ b/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md @@ -94,7 +94,11 @@ cargo add anchor-spl {% callout title = "Disclaimer" %} -todo()! +This example is not a full-fledged implementation suitable for production. To make it production-ready, several additional components and considerations are necessary: + +- **Event Emission**: Use the `event!()` macro to emit events for important actions, such as successful claims or updates to the Merkle root. Alternatively, you can integrate with Solana's noop program to log updates and facilitate data indexing for off-chain applications. + +- **Database Hosting**: You'll need to store and host the complete Merkle tree dataset off-chain and derive hashes for leaves and internal nodes other than generate and serve Merkle proofs dynamically and validate input consistency for claims. {% /callout %} From 37357f1c5c2b407f5c50848b3f0ffa14edff9e0c Mon Sep 17 00:00:00 2001 From: L0STE <125566964+L0STE@users.noreply.github.com> Date: Mon, 13 Jan 2025 19:06:27 +0100 Subject: [PATCH 4/5] Fixed callout --- .../guides/anchor/token-claimer-smart-contract.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md b/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md index 73054eda..00d15821 100644 --- a/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md +++ b/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md @@ -92,7 +92,7 @@ cargo add anchor-spl ## The program -{% callout title = "Disclaimer" %} +{% callout %} This example is not a full-fledged implementation suitable for production. To make it production-ready, several additional components and considerations are necessary: From 14e9d1e3baca46153b74d1d8c2dc6096d49f97d5 Mon Sep 17 00:00:00 2001 From: L0STE <125566964+L0STE@users.noreply.github.com> Date: Thu, 16 Jan 2025 18:39:15 +0100 Subject: [PATCH 5/5] Fixed comment --- .../anchor/token-claimer-smart-contract.md | 83 ++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md b/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md index 00d15821..6c93dce8 100644 --- a/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md +++ b/src/pages/token-metadata/guides/anchor/token-claimer-smart-contract.md @@ -35,6 +35,55 @@ Parent2 = Hash(Hash(C) + Hash(D)) Root = Hash(Parent1 + Parent2) ``` +{% diagram %} + +{% node #root label="Root Node" theme="slate" /%} +{% node #root-hash label="Hash" parent="root" x="56" y="40" theme="transparent" /%} +{% node #node-1 label="Node 1" parent="root" y="100" x="-200" theme="blue" /%} +{% node #node-1-hash label="Hash" parent="node-1" x="42" y="40" theme="transparent" /%} +{% node #node-2 label="Node 2" parent="root" y="100" x="200" theme="mint" /%} + +{% node #node-3 label="Node 3" parent="node-1" y="100" x="-100" theme="mint" /%} +{% node #node-4 label="Node 4" parent="node-1" y="100" x="100" theme="blue" /%} +{% node #node-4-hash label="Hash" parent="node-4" x="42" y="40" theme="transparent" /%} +{% node #node-5 label="Node 5" parent="node-2" y="100" x="-100" /%} +{% node #node-6 label="Node 6" parent="node-2" y="100" x="100" /%} + +{% node #leaf-1 label="Leaf 1" parent="node-3" y="100" x="-45" /%} +{% node #leaf-2 label="Leaf 2" parent="node-3" y="100" x="55" /%} +{% node #leaf-3 label="Leaf 3" parent="node-4" y="100" x="-45" theme="blue" /%} +{% node #leaf-4 label="Leaf 4" parent="node-4" y="100" x="55" theme="mint" /%} +{% node #leaf-5 label="Leaf 5" parent="node-5" y="100" x="-45" /%} +{% node #leaf-6 label="Leaf 6" parent="node-5" y="100" x="55" /%} +{% node #leaf-7 label="Leaf 7" parent="node-6" y="100" x="-45" /%} +{% node #leaf-8 label="Leaf 8" parent="node-6" y="100" x="55" /%} +{% node #nft label="NFT Data" parent="leaf-3" y="100" x="-12" theme="blue" /%} + +{% node #proof-1 label="Leaf 4" parent="nft" x="200" theme="mint" /%} +{% node #proof-2 label="Node 3" parent="proof-1" x="90" theme="mint" /%} +{% node #proof-3 label="Node 2" parent="proof-2" x="97" theme="mint" /%} +{% node #proof-legend label="Proof" parent="proof-1" x="-6" y="-20" theme="transparent" /%} + +{% edge from="node-1" to="root" fromPosition="top" toPosition="bottom" theme="blue" animated=true /%} +{% edge from="node-2" to="root" fromPosition="top" toPosition="bottom" theme="mint" animated=true /%} + +{% edge from="node-3" to="node-1" fromPosition="top" toPosition="bottom" theme="mint" animated=true /%} +{% edge from="node-4" to="node-1" fromPosition="top" toPosition="bottom" theme="blue" animated=true /%} +{% edge from="node-6" to="node-2" fromPosition="top" toPosition="bottom" /%} +{% edge from="node-5" to="node-2" fromPosition="top" toPosition="bottom" /%} + +{% edge from="leaf-1" to="node-3" fromPosition="top" toPosition="bottom" /%} +{% edge from="leaf-2" to="node-3" fromPosition="top" toPosition="bottom" /%} +{% edge from="leaf-4" to="node-4" fromPosition="top" toPosition="bottom" theme="mint" animated=true /%} +{% edge from="leaf-3" to="node-4" fromPosition="top" toPosition="bottom" theme="blue" animated=true /%} +{% edge from="leaf-5" to="node-5" fromPosition="top" toPosition="bottom" /%} +{% edge from="leaf-6" to="node-5" fromPosition="top" toPosition="bottom" /%} +{% edge from="leaf-7" to="node-6" fromPosition="top" toPosition="bottom" /%} +{% edge from="leaf-8" to="node-6" fromPosition="top" toPosition="bottom" /%} +{% edge from="nft" to="leaf-3" fromPosition="top" toPosition="bottom" theme="blue" animated=true label="Hash" /%} + +{% /diagram %} + Merkle trees are a cornerstone of on-chain compression, enabling efficient and secure data verification. By design, altering any part of the dataset invalidates the root, which means the integrity of a specific entry can be verified by storing only the Merkle root on-chain and providing a Merkle Proof—a minimal set of sibling hashes needed to reconstruct the root. - **Key Advantages**: Cost-Efficient Storage: Only the Merkle root (32 bytes) is stored on-chain, significantly reducing storage costs. Verification is achieved by passing Merkle proofs as inputs. @@ -47,7 +96,7 @@ Solana's state compression employs a unique type of Merkle tree that enables mul This specialized tree, called a concurrent Merkle tree, maintains an on-chain changelog, allowing multiple rapid updates to the same tree (e.g., all within the same block) without invalidating proofs. -This functionality is crucial because, on Solana, only one writer per block is allowed per account, meaning only a single change can be made to an account per block, while multiple readers are permitted. The runtime ensures that the account remains secure and cannot be corrupted. However, since every action involves writing—because the Merkle root must be re-uploaded—the concurrent Merkle tree provides a solution for handling multiple updates seamlessly within the same block. +This functionality is essential on Solana where only one writer per block is allowed per account. This limites updates to a single change per block, so the runtime can ensure the account's security and prevents corruption. Since every action requires writing, concurrent Merkle tree offers an efficient solution for managing multiple updates within the same block seamlessly. ## Setup @@ -446,6 +495,36 @@ pub fn claim_airdrop( Ok(()) } + +#[derive(Accounts)] +pub struct Claim<'info> { + #[account( + mut, + has_one = mint, + seeds = [b"merkle_tree".as_ref(), mint.key().to_bytes().as_ref()], + bump = airdrop_state.bump + )] + pub airdrop_state: Account<'info, AirdropState>, + pub mint: Account<'info, Mint>, + #[account( + mut, + associated_token::mint = mint, + associated_token::authority = airdrop_state, + )] + pub vault: Account<'info, TokenAccount>, + #[account( + init_if_needed, + payer = signer, + associated_token::mint = mint, + associated_token::authority = signer, + )] + pub signer_ata: Account<'info, TokenAccount>, + #[account(mut)] + pub signer: Signer<'info>, + pub system_program: Program<'info, System>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, +} ``` To ensure accuracy, the root is reconstructed from the input provided in the accounts struct and compared against the stored root on-chain to verify they match. @@ -518,7 +597,7 @@ pub struct UserReceipt { {% /totem %} -**Note**: The `user_receipt` account could be left empty to minimize rent costs. However, this makes it harder to check on the frontend if a user has already claimed their tokens. This tradeoff should be considered based on the specific requirements of the program. +**Note**: The `user_receipt` account can be left empty to reduce rent costs. To further optimize, you can save bytes on the discriminator by assigning the account to the program and passing it as an `UncheckedAccount`. A `require()` can then be used to verify ownership of the account, and you would need to add the `assign` instruction from the system program to assign it to the right program. ## Full Example Code