Skip to main content

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:

  1. Off-Chain: Build merkle tree with all claimant data (wallet, amount)
  2. On-Chain: Store only the merkle root (32 bytes)
  3. Claiming: Claimant provides proof + amount
  4. 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:

  1. Take a snapshot of token holders (off-chain)
  2. Build merkle tree with claimant data
  3. Upload merkle tree to IPFS
  4. 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: false state (must be funded first)

Errors:

  • Unauthorized: Caller lacks Contract Admin role
  • InvalidIPFSHashSize: 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_amount is reached
  • Once distributor_balance ≥ total_claim_amount, ready_to_claim becomes true

Errors:

  • InvalidFundingAmount: Amount is 0
  • TransferFeeIsNotAllowedForPaymentMint: 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_claimed and num_nodes_claimed

Errors:

  • InvalidProof: Merkle proof verification failed
  • DropAlreadyClaimed: This claim was already claimed
  • ExceededMaxClaim: Claim would exceed total_claim_amount
  • ExceededNumNodes: Index exceeds num_nodes
  • DistributionPaused: Distributor is paused
  • DistributorNotReadyToClaim: 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 role
  • ValueUnchanged: Already in requested pause state

Setting Reclaimer

Setting the reclaimer is a two-step process for security:

  1. Propose Reclaimer (Contract Admin): Proposes a new reclaimer address
  2. 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 reclaimer
  • InvalidProof: Merkle proof invalid
  • DropAlreadyClaimed: 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 seed
  • root ([u8; 32]): Merkle root of distribution tree
  • total_claim_amount (u64): Total dividends to distribute
  • num_nodes (u64): Total number of claimants
  • ipfs_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 PDA
  • mint (InterfaceAccount<Mint>): Payment token mint
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • access_control (Account<AccessControl>): Access control account
  • security_mint (InterfaceAccount<Mint>): Security token mint
  • payer (Signer, mut): Pays for account creation
  • system_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_claim to false (must be funded first)
  • Sets paused to false

Errors:

  • Unauthorized: Caller lacks Contract Admin role
  • InvalidIPFSHashSize: 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 bump
  • index (u64): Claimant's position in merkle tree
  • amount (u64): Amount to claim
  • proof (Vec<[u8; 32]>): Merkle proof (array of 32-byte hashes)

Accounts:

  • distributor (Account<MerkleDistributor>, mut): Distributor account
  • claim_status (Account<ClaimStatus>, init): Claim status PDA (prevents double-claiming)
  • from (InterfaceAccount<TokenAccount>, mut): Distributor's token account
  • to (InterfaceAccount<TokenAccount>, mut): Claimant's token account
  • claimant (Signer): Claimant wallet
  • payer (Signer, mut): Pays for claim status account
  • mint (InterfaceAccount<Mint>): Payment token mint
  • token_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_amount
  • index < num_nodes

PDA Seeds (ClaimStatus): ["ClaimStatus", distributor.key(), index.to_le_bytes()]

Effects:

  • Transfers amount from distributor to claimant
  • Creates ClaimStatus account
  • Increments total_amount_claimed by amount
  • Increments num_nodes_claimed by 1

Errors:

  • InvalidProof: Merkle proof verification failed
  • DropAlreadyClaimed: Claim already claimed
  • ExceededMaxClaim: Would exceed total_claim_amount
  • ExceededNumNodes: Index ≥ num_nodes
  • DistributionPaused: Distributor is paused
  • DistributorNotReadyToClaim: 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 account
  • mint (InterfaceAccount<Mint>): Payment token mint
  • from (InterfaceAccount<TokenAccount>, mut): Funder's token account
  • to (InterfaceAccount<TokenAccount>, mut): Distributor's token account
  • funder (Signer): Funder wallet
  • payer (Signer, mut): Pays transaction fee
  • token_program (Program): Token program

Requirements:

  • amount > 0
  • Funder must have sufficient balance
  • Payment token must not have transfer fee extension enabled

Effects:

  • Transfers amount from funder to distributor
  • If distributor balance ≥ total_claim_amount, sets ready_to_claim to true

Errors:

  • InvalidFundingAmount: Amount is 0
  • TransferFeeIsNotAllowedForPaymentMint: 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 account
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • access_control (Account<AccessControl>): Access control account
  • authority (Signer): Must have Contract Admin role

Requirements:

  • Caller must have Contract Admin role
  • New pause state must differ from current state

Effects:

  • Updates paused field
  • When paused, all claims are blocked

Errors:

  • Unauthorized: Caller lacks Contract Admin role
  • ValueUnchanged: 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 account
  • reclaimer (Account<Reclaimer>, init_if_needed, mut): Reclaimer account
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • access_control (Account<AccessControl>): Access control account
  • authority (Signer): Must have Contract Admin role
  • payer (Signer, mut): Pays for account creation
  • system_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 account
  • reclaimer (Account<Reclaimer>, mut): Reclaimer account
  • new_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 bump
  • index (u64): Claimant's position in merkle tree
  • amount (u64): Amount to reclaim
  • proof (Vec<[u8; 32]>): Merkle proof

Accounts:

  • distributor (Account<MerkleDistributor>, mut): Distributor account
  • reclaimer (Account<Reclaimer>): Reclaimer account
  • claim_status (Account<ClaimStatus>, init): Claim status PDA
  • from (InterfaceAccount<TokenAccount>, mut): Distributor's token account
  • to (InterfaceAccount<TokenAccount>, mut): Reclaimer's token account
  • authority (Signer): Must match reclaimer wallet
  • payer (Signer, mut): Pays for claim status account
  • mint (InterfaceAccount<Mint>): Payment token mint
  • token_program (Program): Token program
  • system_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 amount from distributor to reclaimer
  • Creates ClaimStatus marking as reclaimed
  • Increments total_amount_claimed and num_nodes_claimed

Errors:

  • Unauthorized: Caller is not the reclaimer
  • InvalidProof: Merkle proof invalid
  • DropAlreadyClaimed: Entry already claimed
  • ExceededMaxClaim: Would exceed total_claim_amount
  • ExceededNumNodes: 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

ErrorCodeDescription
InvalidProof6000Merkle proof verification failed
DropAlreadyClaimed6001Claim already claimed
ExceededMaxClaim6002Claim would exceed total_claim_amount
ExceededNumNodes6003Index exceeds num_nodes
Unauthorized6004Caller not authorized
OwnerMismatch6005Token account owner mismatch
KeysMustNotMatch6006Keys must not match
InvalidFundingAmount6007Funding amount is 0
DistributionPaused6008Distributor is paused
DistributorNotReadyToClaim6009Not fully funded
InvalidIPFSHashSize6010IPFS hash exceeds 64 characters
ValueUnchanged6011New value same as current
TransferFeeIsNotAllowedForPaymentMint6012Payment token has transfer fee
TotalAmountClaimedOverflow6013Total amount claimed overflow
NumNodesClaimedOverflow6014Number 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 distribution
  • fund_dividends - Fund distribution with payment tokens
  • claim - Claim dividends with merkle proof
  • pause - Emergency pause distributions
  • propose_reclaimer - Propose reclaimer wallet (Step 1)
  • accept_reclaimer_ownership - Accept reclaimer role (Step 2)
  • reclaim_dividends - Reclaim unclaimed dividends