Dividends
Overview
The Dividends program implements a merkle tree-based dividend distribution system for Solana RWA tokens. It enables efficient, trustless dividend payments to token holders based on snapshots of token ownership.
Architecture
- Merkle Tree Distribution: Gas-efficient claiming using merkle proofs
- Snapshot-Based: Distributions based on historical token ownership
- Multi-Token Support: Can distribute any SPL or SPL Token-2022 token
- Flexible Funding: Anyone can fund distributions
- IPFS Integration: Stores merkle tree data off-chain for transparency
Key Features
- Create multiple distributions over time
- Merkle proof-based claiming for gas efficiency
- Anyone can fund dividends
- Claims verified on-chain against merkle root
- Pause/unpause functionality
- Reclaim unclaimed dividends
- IPFS hash storage for merkle tree verification
Core Concepts
Merkle Distributors
A Merkle Distributor is a distribution instance representing one dividend payment event. Each distributor contains:
- Merkle Root: 256-bit root hash of the merkle tree
- Total Claim Amount: Total dividends to be distributed
- Number of Nodes: Total number of eligible claimants
- Payment Token: Which token to distribute (e.g., USDC, USDT)
- Security Token: Which RWA token determines eligibility
- IPFS Hash: Reference to off-chain merkle tree data
Key Properties:
pub struct MerkleDistributor {
pub base: Pubkey, // Base key (PDA seed)
pub bump: u8, // PDA bump
pub root: [u8; 32], // Merkle root
pub mint: Pubkey, // Payment token mint
pub total_claim_amount: u64, // Total dividends
pub num_nodes: u64, // Total claimants
pub total_amount_claimed: u64, // Claimed so far
pub num_nodes_claimed: u64, // Claimants who claimed
pub access_control: Pubkey, // Access control account
pub paused: bool, // Emergency pause
pub ready_to_claim: bool, // Funded and ready
pub ipfs_hash: String, // IPFS reference
}
Merkle Trees
Dividend distributions use merkle trees for efficient on-chain verification. Instead of storing all claims on-chain, only the merkle root is stored.
How It Works:
- Off-Chain: Build merkle tree with all claimant data (wallet, amount)
- On-Chain: Store only the merkle root (32 bytes)
- Claiming: Claimant provides proof + amount
- Verification: On-chain verification of proof against root
Benefits:
- Constant on-chain storage (32 bytes regardless of claimant count)
- Claimants can verify eligibility off-chain
- Trustless: Merkle proof guarantees authenticity
Example Merkle Tree:
Root: 0xabc...
/ \
Hash(A+B): 0x123... Hash(C+D): 0x456...
/ \ / \
A: Alice B: Bob C: Carol D: Dave
100 USDC 200 USDC 150 USDC 250 USDC
Merkle Proof for Alice:
- Leaf:
hash(Alice || 100) - Proof:
[hash(B), hash(C+D)] - Verification:
root == hash(hash(hash(Alice||100) + hash(B)) + hash(C+D))
Claim Status
Each claim is tracked in a ClaimStatus account to prevent double-claiming.
Structure:
pub struct ClaimStatus {
pub is_claimed: bool, // Already claimed?
pub claimant: Pubkey, // Who claimed
pub claimed_at: i64, // Timestamp
pub amount: u64, // Amount claimed
}
PDA Seeds: ["ClaimStatus", distributor.key(), index.to_le_bytes()]
Where index is the claimant's position in the merkle tree.
Usage
Creating a Distributor
Contract Admins create new distributors for each dividend event.
Prerequisites:
- Take a snapshot of token holders (off-chain)
- Build merkle tree with claimant data
- Upload merkle tree to IPFS
- Calculate merkle root
TypeScript Example:
import { MerkleTree } from "merkletreejs";
import keccak256 from "keccak256";
// 1. Build merkle tree off-chain
const claimants = [
{ wallet: wallet1.publicKey, amount: new anchor.BN(100_000_000) }, // 100 USDC
{ wallet: wallet2.publicKey, amount: new anchor.BN(200_000_000) }, // 200 USDC
{ wallet: wallet3.publicKey, amount: new anchor.BN(150_000_000) }, // 150 USDC
];
// Create leaves (hash of wallet + amount)
const leaves = claimants.map((c, index) =>
Buffer.concat([
Buffer.from(new Uint8Array(c.wallet.toBytes())),
Buffer.from(new Uint8Array(c.amount.toArray("le", 8))),
Buffer.from(new Uint8Array(new anchor.BN(index).toArray("le", 8))),
])
);
const tree = new MerkleTree(leaves.map(keccak256), keccak256, {
sortPairs: true,
});
const root = tree.getRoot();
// 2. Upload to IPFS (example)
const ipfsHash = await uploadToIPFS(JSON.stringify({ claimants, tree }));
// 3. Create distributor
const baseKey = anchor.web3.Keypair.generate();
const [distributorPDA, bump] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("MerkleDistributor"), baseKey.publicKey.toBuffer()],
dividendsProgramId
);
const totalClaimAmount = claimants.reduce(
(sum, c) => sum.add(c.amount),
new anchor.BN(0)
);
await dividendsProgram.methods
.newDistributor(
bump,
Array.from(root),
totalClaimAmount,
new anchor.BN(claimants.length),
ipfsHash
)
.accountsStrict({
base: baseKey.publicKey,
distributor: distributorPDA,
mint: usdcMint, // Payment token (e.g., USDC)
authorityWalletRole: contractAdminWalletRolePDA,
accessControl: accessControlPDA,
securityMint: securityTokenMint, // RWA token
payer: payer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([baseKey, payer, contractAdmin])
.rpc();
Requirements:
- Caller must have Contract Admin role
- Base key must be a new Keypair
- Merkle root must be exactly 32 bytes
- IPFS hash must be ≤ 64 characters
- Security mint must match access control
- Distributor starts in
ready_to_claim: falsestate (must be funded first)
Errors:
Unauthorized: Caller lacks Contract Admin roleInvalidIPFSHashSize: IPFS hash too long
Funding a Distributor
Anyone can fund a distributor with the payment token. Once fully funded, it becomes claimable.
TypeScript Example:
import {
getAssociatedTokenAddressSync,
TOKEN_2022_PROGRAM_ID,
} from "@solana/spl-token";
const funderATA = getAssociatedTokenAddressSync(
usdcMint,
funder.publicKey,
false,
TOKEN_PROGRAM_ID // Or TOKEN_2022_PROGRAM_ID depending on payment token
);
const distributorATA = getAssociatedTokenAddressSync(
usdcMint,
distributorPDA,
true, // allowOwnerOffCurve
TOKEN_PROGRAM_ID
);
const fundAmount = new anchor.BN(450_000_000); // 450 USDC (full amount)
await dividendsProgram.methods
.fundDividends(fundAmount)
.accountsStrict({
distributor: distributorPDA,
mint: usdcMint,
from: funderATA,
to: distributorATA,
funder: funder.publicKey,
payer: payer.publicKey,
tokenProgram: TOKEN_PROGRAM_ID,
})
.signers([funder, payer])
.rpc();
Requirements:
- Funder must have sufficient payment token balance
- Funding amount must be > 0
- Can be funded multiple times until
total_claim_amountis reached - Once
distributor_balance ≥ total_claim_amount,ready_to_claimbecomestrue
Errors:
InvalidFundingAmount: Amount is 0TransferFeeIsNotAllowedForPaymentMint: Payment token has transfer fee (not supported)
Note: Distributor ATA must exist before funding. Create it first using createAssociatedTokenAccount.
Claiming Dividends
Eligible claimants can claim their dividends by providing a merkle proof.
TypeScript Example:
const claimantIndex = 0; // Alice is first in the tree
const claimAmount = new anchor.BN(100_000_000); // 100 USDC
// Generate merkle proof
const leaf = keccak256(
Buffer.concat([
Buffer.from(new Uint8Array(wallet1.publicKey.toBytes())),
Buffer.from(new Uint8Array(claimAmount.toArray("le", 8))),
Buffer.from(new Uint8Array(new anchor.BN(claimantIndex).toArray("le", 8))),
])
);
const proof = tree.getProof(leaf).map((p) => Array.from(p.data));
// Create claim status PDA
const [claimStatusPDA, claimBump] =
anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("ClaimStatus"),
distributorPDA.toBuffer(),
Buffer.from(new Uint8Array(new anchor.BN(claimantIndex).toArray("le", 8))),
],
dividendsProgramId
);
const claimantATA = getAssociatedTokenAddressSync(
usdcMint,
claimant.publicKey,
false,
TOKEN_PROGRAM_ID
);
await dividendsProgram.methods
.claim(claimBump, new anchor.BN(claimantIndex), claimAmount, proof)
.accountsStrict({
distributor: distributorPDA,
claimStatus: claimStatusPDA,
from: distributorATA,
to: claimantATA,
claimant: claimant.publicKey,
payer: payer.publicKey,
mint: usdcMint,
tokenProgram: TOKEN_PROGRAM_ID,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([claimant, payer])
.rpc();
Requirements:
- Distributor must be funded (
ready_to_claim == true) - Distributor must not be paused
- Merkle proof must be valid
- Claim must not have been claimed before (checked via ClaimStatus)
- Claim amount + total_amount_claimed must not exceed total_claim_amount
- Index must be < num_nodes
Effects:
- Transfers claim amount from distributor to claimant
- Creates ClaimStatus account marking claim as completed
- Increments
total_amount_claimedandnum_nodes_claimed
Errors:
InvalidProof: Merkle proof verification failedDropAlreadyClaimed: This claim was already claimedExceededMaxClaim: Claim would exceed total_claim_amountExceededNumNodes: Index exceeds num_nodesDistributionPaused: Distributor is pausedDistributorNotReadyToClaim: Distributor not fully funded
Pausing a Distributor
Contract Admins can pause distributions in emergency situations.
TypeScript Example:
// Pause
await dividendsProgram.methods
.pause(true)
.accountsStrict({
distributor: distributorPDA,
authorityWalletRole: contractAdminWalletRolePDA,
accessControl: accessControlPDA,
authority: contractAdmin.publicKey,
})
.signers([contractAdmin])
.rpc();
// Unpause
await dividendsProgram.methods
.pause(false)
.accountsStrict({
/* same accounts */
})
.signers([contractAdmin])
.rpc();
Requirements:
- Caller must have Contract Admin role
- Pausing prevents all claims until unpaused
Errors:
Unauthorized: Caller lacks Contract Admin roleValueUnchanged: Already in requested pause state
Setting Reclaimer
Setting the reclaimer is a two-step process for security:
- Propose Reclaimer (Contract Admin): Proposes a new reclaimer address
- Accept Ownership (Proposed Reclaimer): New reclaimer accepts the role
Step 1: Propose Reclaimer (Contract Admin):
await dividendsProgram.methods
.proposeReclaimer(newReclaimerWallet.publicKey)
.accountsStrict({
distributor: distributorPDA,
reclaimer: reclaimerPDA,
authorityWalletRole: contractAdminWalletRolePDA,
accessControl: accessControlPDA,
authority: contractAdmin.publicKey,
payer: payer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([contractAdmin, payer])
.rpc();
Step 2: Accept Reclaimer Ownership (Proposed Reclaimer):
await dividendsProgram.methods
.acceptReclaimerOwnership()
.accountsStrict({
distributor: distributorPDA,
reclaimer: reclaimerPDA,
newReclaimer: newReclaimerWallet.publicKey,
})
.signers([newReclaimerWallet])
.rpc();
Requirements:
- Propose: Caller must have Contract Admin role
- Accept: Caller must match the proposed reclaimer address
Errors:
Unauthorized: Caller lacks required permissions
Reclaiming Dividends
The reclaimer can reclaim unclaimed dividends after setting themselves as reclaimer.
TypeScript Example:
const reclaimerATA = getAssociatedTokenAddressSync(
usdcMint,
reclaimerWallet.publicKey,
false,
TOKEN_PROGRAM_ID
);
// Must provide valid proof for a specific unclaimed entry
// (to prevent front-running)
const unclaimedIndex = 2; // Carol hasn't claimed
const unclaimedAmount = new anchor.BN(150_000_000);
const leaf = keccak256(
Buffer.concat([
Buffer.from(new Uint8Array(carol.publicKey.toBytes())),
Buffer.from(new Uint8Array(unclaimedAmount.toArray("le", 8))),
Buffer.from(
new Uint8Array(new anchor.BN(unclaimedIndex).toArray("le", 8))
),
])
);
const proof = tree.getProof(leaf).map((p) => Array.from(p.data));
const [claimStatusPDA, claimBump] =
anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("ClaimStatus"),
distributorPDA.toBuffer(),
Buffer.from(new Uint8Array(new anchor.BN(unclaimedIndex).toArray("le", 8))),
],
dividendsProgramId
);
await dividendsProgram.methods
.reclaimDividends(claimBump, new anchor.BN(unclaimedIndex), unclaimedAmount, proof)
.accountsStrict({
distributor: distributorPDA,
reclaimer: reclaimerPDA, // From distributor state
claimStatus: claimStatusPDA,
from: distributorATA,
to: reclaimerATA,
authority: reclaimerWallet.publicKey,
payer: payer.publicKey,
mint: usdcMint,
tokenProgram: TOKEN_PROGRAM_ID,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([reclaimerWallet, payer])
.rpc();
Requirements:
- Caller must match the reclaimer address in distributor
- Merkle proof must be valid
- Claim must not have been claimed before
- Can reclaim on behalf of any unclaimed entry
Effects:
- Transfers unclaimed amount to reclaimer
- Creates ClaimStatus marking entry as reclaimed
Errors:
Unauthorized: Caller is not the reclaimerInvalidProof: Merkle proof invalidDropAlreadyClaimed: Entry already claimed
API Reference
Instructions
new_distributor(ctx: Context\<NewDistributor\>, _bump: u8, root: [u8; 32], total_claim_amount: u64, num_nodes: u64, ipfs_hash: String) -> Result<()>
Creates a new merkle distributor for dividend distribution.
Parameters:
_bump(u8): PDA bump seedroot([u8; 32]): Merkle root of distribution treetotal_claim_amount(u64): Total dividends to distributenum_nodes(u64): Total number of claimantsipfs_hash(String): IPFS hash of merkle tree data (max 64 chars)
Accounts:
base(Signer): Base key for PDA (must be Keypair)distributor(Account<MerkleDistributor>, init): New distributor PDAmint(InterfaceAccount<Mint>): Payment token mintauthority_wallet_role(Account<WalletRole>): Authority's role accountaccess_control(Account<AccessControl>): Access control accountsecurity_mint(InterfaceAccount<Mint>): Security token mintpayer(Signer, mut): Pays for account creationsystem_program(Program): Solana System Program
Requirements:
- Caller must have Contract Admin role
- IPFS hash length ≤ 64 characters
- Security mint must match access control mint
PDA Seeds: ["MerkleDistributor", base.key()]
Effects:
- Creates new distributor account
- Sets
ready_to_claimtofalse(must be funded first) - Sets
pausedtofalse
Errors:
Unauthorized: Caller lacks Contract Admin roleInvalidIPFSHashSize: IPFS hash too long
claim(ctx: Context\<Claim\>, _bump: u8, index: u64, amount: u64, proof: Vec<[u8; 32]>) -> Result<()>
Claims dividends for a claimant using merkle proof.
Parameters:
_bump(u8): Claim status PDA bumpindex(u64): Claimant's position in merkle treeamount(u64): Amount to claimproof(Vec<[u8; 32]>): Merkle proof (array of 32-byte hashes)
Accounts:
distributor(Account<MerkleDistributor>, mut): Distributor accountclaim_status(Account<ClaimStatus>, init): Claim status PDA (prevents double-claiming)from(InterfaceAccount<TokenAccount>, mut): Distributor's token accountto(InterfaceAccount<TokenAccount>, mut): Claimant's token accountclaimant(Signer): Claimant walletpayer(Signer, mut): Pays for claim status accountmint(InterfaceAccount<Mint>): Payment token minttoken_program(Program): Token program (SPL or Token-2022)system_program(Program): Solana System Program
Requirements:
- Distributor must be funded (
ready_to_claim == true) - Distributor must not be paused
- Merkle proof must verify against root
- Claim must not have been claimed before
total_amount_claimed + amount ≤ total_claim_amountindex < num_nodes
PDA Seeds (ClaimStatus): ["ClaimStatus", distributor.key(), index.to_le_bytes()]
Effects:
- Transfers
amountfrom distributor to claimant - Creates ClaimStatus account
- Increments
total_amount_claimedbyamount - Increments
num_nodes_claimedby 1
Errors:
InvalidProof: Merkle proof verification failedDropAlreadyClaimed: Claim already claimedExceededMaxClaim: Would exceed total_claim_amountExceededNumNodes: Index ≥ num_nodesDistributionPaused: Distributor is pausedDistributorNotReadyToClaim: Not fully funded
fund_dividends(ctx: Context\<FundDividends\>, amount: u64) -> Result<()>
Funds a distributor with payment tokens.
Parameters:
amount(u64): Amount to fund
Accounts:
distributor(Account<MerkleDistributor>, mut): Distributor accountmint(InterfaceAccount<Mint>): Payment token mintfrom(InterfaceAccount<TokenAccount>, mut): Funder's token accountto(InterfaceAccount<TokenAccount>, mut): Distributor's token accountfunder(Signer): Funder walletpayer(Signer, mut): Pays transaction feetoken_program(Program): Token program
Requirements:
amount > 0- Funder must have sufficient balance
- Payment token must not have transfer fee extension enabled
Effects:
- Transfers
amountfrom funder to distributor - If distributor balance ≥
total_claim_amount, setsready_to_claimtotrue
Errors:
InvalidFundingAmount: Amount is 0TransferFeeIsNotAllowedForPaymentMint: Token has transfer fee
pause(ctx: Context\<Pause\>, paused: bool) -> Result<()>
Pauses or unpauses a distributor.
Parameters:
paused(bool): True to pause, false to unpause
Accounts:
distributor(Account<MerkleDistributor>, mut): Distributor accountauthority_wallet_role(Account<WalletRole>): Authority's role accountaccess_control(Account<AccessControl>): Access control accountauthority(Signer): Must have Contract Admin role
Requirements:
- Caller must have Contract Admin role
- New pause state must differ from current state
Effects:
- Updates
pausedfield - When paused, all claims are blocked
Errors:
Unauthorized: Caller lacks Contract Admin roleValueUnchanged: Already in requested state
propose_reclaimer(ctx: Context\<ProposeReclaimer\>, new_reclaimer: Pubkey) -> Result<()>
Proposes a new reclaimer wallet address (Step 1 of 2).
Parameters:
new_reclaimer(Pubkey): Proposed reclaimer address
Accounts:
distributor(Account<MerkleDistributor>, mut): Distributor accountreclaimer(Account<Reclaimer>, init_if_needed, mut): Reclaimer accountauthority_wallet_role(Account<WalletRole>): Authority's role accountaccess_control(Account<AccessControl>): Access control accountauthority(Signer): Must have Contract Admin rolepayer(Signer, mut): Pays for account creationsystem_program(Program): Solana System Program
Requirements:
- Caller must have Contract Admin role
PDA Seeds (Reclaimer): ["Reclaimer", distributor.key()]
Effects:
- Creates or updates reclaimer account with proposed address
- Proposed reclaimer must accept ownership
Errors:
Unauthorized: Caller lacks Contract Admin role
accept_reclaimer_ownership(ctx: Context\<AcceptReclaimerOwnership\>) -> Result<()>
Accepts reclaimer role (Step 2 of 2). Must be called by the proposed reclaimer.
Accounts:
distributor(Account<MerkleDistributor>, mut): Distributor accountreclaimer(Account<Reclaimer>, mut): Reclaimer accountnew_reclaimer(Signer): Proposed reclaimer (must match)
Requirements:
- Caller must match the proposed reclaimer address in the reclaimer account
Effects:
- Activates the reclaimer role
- Reclaimer can now call
reclaim_dividends
Errors:
Unauthorized: Caller is not the proposed reclaimer
reclaim_dividends(ctx: Context\<ReclaimDividends\>, _bump: u8, index: u64, amount: u64, proof: Vec<[u8; 32]>) -> Result<()>
Reclaims unclaimed dividends to the reclaimer wallet.
Parameters:
_bump(u8): Claim status PDA bumpindex(u64): Claimant's position in merkle treeamount(u64): Amount to reclaimproof(Vec<[u8; 32]>): Merkle proof
Accounts:
distributor(Account<MerkleDistributor>, mut): Distributor accountreclaimer(Account<Reclaimer>): Reclaimer accountclaim_status(Account<ClaimStatus>, init): Claim status PDAfrom(InterfaceAccount<TokenAccount>, mut): Distributor's token accountto(InterfaceAccount<TokenAccount>, mut): Reclaimer's token accountauthority(Signer): Must match reclaimer walletpayer(Signer, mut): Pays for claim status accountmint(InterfaceAccount<Mint>): Payment token minttoken_program(Program): Token programsystem_program(Program): Solana System Program
Requirements:
- Caller must match reclaimer wallet address
- Merkle proof must be valid
- Claim must not have been claimed before
Effects:
- Transfers
amountfrom distributor to reclaimer - Creates ClaimStatus marking as reclaimed
- Increments
total_amount_claimedandnum_nodes_claimed
Errors:
Unauthorized: Caller is not the reclaimerInvalidProof: Merkle proof invalidDropAlreadyClaimed: Entry already claimedExceededMaxClaim: Would exceed total_claim_amountExceededNumNodes: Index ≥ num_nodes
Account Structures
MerkleDistributor
#[account]
pub struct MerkleDistributor {
pub base: Pubkey, // Base key for PDA
pub bump: u8, // PDA bump
pub root: [u8; 32], // Merkle root
pub mint: Pubkey, // Payment token mint
pub total_claim_amount: u64, // Total to distribute
pub num_nodes: u64, // Total claimants
pub total_amount_claimed: u64, // Amount claimed so far
pub num_nodes_claimed: u64, // Claimants who claimed
pub access_control: Pubkey, // Access control account
pub paused: bool, // Emergency pause
pub ready_to_claim: bool, // Fully funded?
pub ipfs_hash: String, // IPFS reference (max 64 chars)
}
PDA Seeds: ["MerkleDistributor", base.key()]
ClaimStatus
#[account]
pub struct ClaimStatus {
pub is_claimed: bool, // Claimed?
pub claimant: Pubkey, // Who claimed
pub claimed_at: i64, // Timestamp
pub amount: u64, // Amount claimed
}
PDA Seeds: ["ClaimStatus", distributor.key(), index.to_le_bytes()]
Reclaimer
#[account]
pub struct Reclaimer {
pub wallet: Pubkey, // Reclaimer wallet address
}
PDA Seeds: ["Reclaimer", distributor.key()]
Errors
DividendsErrorCode
| Error | Code | Description |
|---|---|---|
InvalidProof | 6000 | Merkle proof verification failed |
DropAlreadyClaimed | 6001 | Claim already claimed |
ExceededMaxClaim | 6002 | Claim would exceed total_claim_amount |
ExceededNumNodes | 6003 | Index exceeds num_nodes |
Unauthorized | 6004 | Caller not authorized |
OwnerMismatch | 6005 | Token account owner mismatch |
KeysMustNotMatch | 6006 | Keys must not match |
InvalidFundingAmount | 6007 | Funding amount is 0 |
DistributionPaused | 6008 | Distributor is paused |
DistributorNotReadyToClaim | 6009 | Not fully funded |
InvalidIPFSHashSize | 6010 | IPFS hash exceeds 64 characters |
ValueUnchanged | 6011 | New value same as current |
TransferFeeIsNotAllowedForPaymentMint | 6012 | Payment token has transfer fee |
TotalAmountClaimedOverflow | 6013 | Total amount claimed overflow |
NumNodesClaimedOverflow | 6014 | Number of nodes claimed overflow |
Events
DistributorCreated
pub struct DistributorCreated {
pub distributor: Pubkey,
pub mint: Pubkey,
pub total_claim_amount: u64,
pub num_nodes: u64,
}
Emitted when a new distributor is created.
Claimed
pub struct Claimed {
pub distributor: Pubkey,
pub claimant: Pubkey,
pub index: u64,
pub amount: u64,
}
Emitted when a claim is made.
DistributorFunded
pub struct DistributorFunded {
pub distributor: Pubkey,
pub funder: Pubkey,
pub amount: u64,
}
Emitted when a distributor is funded.
Integration Notes
With Access Control
- Contract Admin role required for creating distributors and pausing
- Uses access control account for role verification
Merkle Tree Generation
Recommended Libraries:
- JavaScript:
merkletreejs+keccak256 - Rust:
rs-merkle
Leaf Structure:
const leaf = keccak256(
Buffer.concat([
claimant.publicKey.toBuffer(), // 32 bytes
amount.toArrayLike(Buffer, "le", 8), // 8 bytes (u64)
index.toArrayLike(Buffer, "le", 8), // 8 bytes (u64)
])
);
Important: Use consistent leaf structure across tree generation and claiming.
IPFS Storage
Store the following data on IPFS for transparency:
{
"merkleRoot": "0xabc...",
"totalAmount": "1000000000",
"claimants": [
{ "index": 0, "wallet": "5Rm...", "amount": "100000000" },
{ "index": 1, "wallet": "7Xt...", "amount": "200000000" }
],
"tree": {
"layers": [...] // Optional: full tree for verification
}
}
Payment Tokens
- Can use any SPL or SPL Token-2022 token
- Important: Payment token must NOT have transfer fee extension enabled
- Common payment tokens: USDC, USDT, EURC
Security Considerations
Merkle Root Security
- Merkle root is immutable after creation
- Ensure off-chain tree generation is correct
- Double-check claimant amounts before deploying
- Store merkle tree data securely (IPFS + backup)
Funding
- Anyone can fund, which is intentional (community funding)
- Only concern: Over-funding (extra funds can be reclaimed)
- Reclaimer mechanism allows recovery of unclaimed funds
Front-Running
- Claiming requires valid merkle proof
- No way to front-run another user's claim
- ClaimStatus prevents double-claiming
Reclaimer Risk
- Reclaimer can claim unclaimed entries
- Set reclaimer only after sufficient distribution period
- Consider using timelock or DAO for reclaimer
Common Patterns
Quarterly Dividend Distribution
Multiple Distributions
// Q1 Distribution
const q1Distributor = await createDistributor(q1Snapshot, "Q1-2024");
await fundDistributor(q1Distributor, usdcMint);
// Q2 Distribution (independent)
const q2Distributor = await createDistributor(q2Snapshot, "Q2-2024");
await fundDistributor(q2Distributor, usdcMint);
// Users can claim from both
await claimFromDistributor(q1Distributor, wallet, q1Proof);
await claimFromDistributor(q2Distributor, wallet, q2Proof);
Program ID
Mainnet/Devnet: FUjkkUVKa9Pofs5mBdiYQe2cBVwzrhX8SunAZhGXRkog
IDL
The Interface Definition Language (IDL) file contains the complete program interface including all instructions, accounts, types, and errors.
Download: dividends.json
Usage with Anchor:
import { Program, AnchorProvider } from "@coral-xyz/anchor";
import idl from "./dividends.json";
const programId = new PublicKey("FUjkkUVKa9Pofs5mBdiYQe2cBVwzrhX8SunAZhGXRkog");
const program = new Program(idl, programId, provider);
Key Instructions in IDL:
new_distributor- Create new dividend distributionfund_dividends- Fund distribution with payment tokensclaim- Claim dividends with merkle proofpause- Emergency pause distributionspropose_reclaimer- Propose reclaimer wallet (Step 1)accept_reclaimer_ownership- Accept reclaimer role (Step 2)reclaim_dividends- Reclaim unclaimed dividends