Contract
Table of Contents
Section titled “Table of Contents”- Architecture Overview
- Imports & Dependencies
- Data Structures
- Instructions (Functions)
- Account Structures
- Security & Validation
Architecture Overview
Section titled “Architecture Overview”What this contract does: This is an automated affiliate commission payment system on Solana. Merchants create pools with USDC funds, add affiliates with unique referral codes, and when sales happen, commissions are paid automatically from escrow.
Key Components:
- MerchantPool: Campaign settings (commission rate, stats)
- AffiliateAccount: Individual affiliate data (earnings, ref code)
- Escrow: USDC held by the program to pay commissions
- PDAs (Program Derived Addresses): Accounts controlled by the program
Imports & Dependencies
Section titled “Imports & Dependencies”use anchor_lang::prelude::*;What it includes:
Account- Wrapper for deserialized account dataPubkey- 32-byte address typeSigner- Account that must sign the transactionSystem,Program- Account types- Macros:
#[program],#[account],#[derive], etc.
use anchor_spl::{
associated_token::AssociatedToken,
token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked},
};Why these imports:
AssociatedToken- Creates deterministic token accounts (one per wallet per mint)Mint- USDC token mint account typeTokenAccount- Account holding tokensTokenInterface- Supports both Token and Token-2022 programsTransferChecked- Transfer tokens with decimal validation
declare_id!("CFQoHeX28aKhpgsLCSGM2zpou6RkRrwRoHVToWS2B6tQ");What this does:
- Sets program’s unique address on Solana
- Generated when you run
anchor build - Used to verify the program is executing on-chain
Data Structures
Section titled “Data Structures”1. MerchantPool - The Campaign Configuration
Section titled “1. MerchantPool - The Campaign Configuration”#[account]
#[derive(InitSpace)]
pub struct MerchantPool {
pub merchant: Pubkey, // 32 bytes
pub usdc_mint: Pubkey, // 32 bytes
pub commission_rate: u16, // 2 bytes
pub total_volume: u64, // 8 bytes
pub total_commissions_paid: u64, // 8 bytes
pub bump: u8, // 1 byte
pub escrow_bump: u8, // 1 byte
}
// Total: 84 bytes + 8 byte discriminator = 92 bytesField-by-field breakdown:
merchant: Pubkey
- The wallet that owns this pool
- Only this wallet can add/remove affiliates
- From test:
merchant.publicKey
usdc_mint: Pubkey
- Address of the USDC token mint
- From test: Created with
createMint(..., 6 decimals, ...) - Ensures all transfers use the correct token
commission_rate: u16
- Stored in basis points (1 bp = 0.01%)
- Range: 0-10000 (0%-100%)
- From test:
1000= 10% - Formula: commission = sale_amount × rate ÷ 10000
total_volume: u64
- Cumulative sales amount processed through this pool
- Incremented in
process_sale - Useful for merchant analytics
total_commissions_paid: u64
- Total USDC paid to all affiliates
- From test: After processing 50 USDC sale with 10% rate = 5 USDC added
bump: u8
- PDA bump seed for
merchant_poolaccount - Stored to avoid recomputing
- Ensures the PDA is off the ed25519 curve
escrow_bump: u8
- PDA bump seed for
escrow_authority - Used when signing transfers from escrow
Why #[derive(InitSpace)]?
- Auto-calculates space needed for account
- Replaces manual space calculation
- Accounts for dynamic types (String, Vec)
2. AffiliateAccount - The Affiliate Profile
Section titled “2. AffiliateAccount - The Affiliate Profile”#[account]
#[derive(InitSpace)]
pub struct AffiliateAccount {
pub pool: Pubkey, // 32 bytes - Link to merchant pool
pub wallet: Pubkey, // 32 bytes - Payment destination
#[max_len(32)]
pub ref_id: String, // 4 + 32 = 36 bytes
pub total_earned: u64, // 8 bytes - Lifetime earnings
pub sales_count: u64, // 8 bytes - Number of sales
pub is_active: bool, // 1 byte - Can earn commissions?
pub bump: u8, // 1 byte - PDA bump
pub created_at: i64, // 8 bytes - Unix timestamp
}
// Total: 126 bytes + 8 byte discriminator = 134 bytesField-by-field breakdown:
pool: Pubkey
- Links this affiliate to a specific merchant pool
- From test:
merchantPoolPda - Validated with:
constraint = affiliate_account.pool == merchant_pool.key()
wallet: Pubkey
- Where commissions are sent
- From test:
affiliate.publicKey - Used to derive
affiliate_usdctoken account
ref_id: String with #[max_len(32)]
- Unique referral code (e.g., “ALICE123”, “SUMMER50”)
- From test:
"AFF001" - Max 32 chars, validation:
require!(ref_id.len() > 0 && ref_id.len() <= 32, ...) - Space: 4 bytes (String length prefix) + 32 bytes (max content)
total_earned: u64
- Cumulative USDC earned by this affiliate
- From test: After 50 USDC sale → 5 USDC (5_000_000 with 6 decimals)
- Incremented in
process_sale
sales_count: u64
- Number of successful referrals
- From test: Starts at 0, becomes 1 after first sale
- Useful for leaderboards
is_active: bool
- Can they earn commissions?
- From test:
trueafteradd_affiliate,falseafterremove_affiliate - Soft delete (preserves history)
created_at: i64
- Unix timestamp when affiliate was added
- Uses
Clock::get()?.unix_timestamp
Instructions (Functions)
Section titled “Instructions (Functions)”1. initialize_pool - Creating the Campaign
Section titled “1. initialize_pool - Creating the Campaign”pub fn initialize_pool(
ctx: Context<InitializePool>,
commission_rate: u16,
initial_deposit: u64,
) -> Result<()>Step-by-step execution:
Step 1: Validate commission rate
require!(commission_rate <= 10000, ErrorCode::InvalidCommissionRate);- Test case: Tries
10001→ ExpectsInvalidCommissionRateerror - Prevents impossible rates like 150%
Step 2: Validate deposit
require!(initial_deposit > 0, ErrorCode::InvalidAmount);- Can be 0 if merchant plans to deposit later
- From test:
100_000_000(100 USDC with 6 decimals)
Step 3: Initialize pool data
let pool = &mut ctx.accounts.merchant_pool;
pool.merchant = ctx.accounts.merchant.key();
pool.usdc_mint = ctx.accounts.usdc_mint.key();
pool.commission_rate = commission_rate;
pool.total_volume = 0;
pool.total_commissions_paid = 0;
pool.bump = ctx.bumps.merchant_pool;
pool.escrow_bump = ctx.bumps.escrow_authority;ctx.bumpscontains PDA bumps found during account derivation- All stats start at 0
Step 4: Transfer initial deposit (if > 0)
if initial_deposit > 0 {
let decimals = ctx.accounts.usdc_mint.decimals;
token_interface::transfer_checked(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
TransferChecked {
from: ctx.accounts.merchant_usdc.to_account_info(),
mint: ctx.accounts.usdc_mint.to_account_info(),
to: ctx.accounts.escrow_usdc.to_account_info(),
authority: ctx.accounts.merchant.to_account_info(),
},
),
initial_deposit,
decimals,
)?;
}Why transfer_checked vs transfer?
transfer_checkedvalidates decimals match the mint- Prevents bugs from decimal mismatches
- Required for Token-2022 compatibility
Step 5: Emit event
emit!(PoolInitialized {
pool: pool.key(),
merchant: pool.merchant,
commission_rate,
initial_deposit,
timestamp: Clock::get()?.unix_timestamp,
});- Off-chain indexers can track this
- Creates audit trail
Account Structure for InitializePool:
#[derive(Accounts)]
pub struct InitializePool<'info> {
#[account(
init, // Creates the account
payer = merchant, // Merchant pays rent
space = 8 + MerchantPool::INIT_SPACE, // 8 = discriminator
seeds = [b"pool", merchant.key().as_ref()],
bump
)]
pub merchant_pool: Account<'info, MerchantPool>,
#[account(mut)] // mut = can be debited
pub merchant: Signer<'info>, // Must sign transaction
#[account(
mut,
constraint = merchant_usdc.owner == merchant.key(),
constraint = merchant_usdc.mint == usdc_mint.key()
)]
pub merchant_usdc: InterfaceAccount<'info, TokenAccount>,
#[account(
seeds = [b"escrow_authority", merchant_pool.key().as_ref()],
bump
)]
pub escrow_authority: UncheckedAccount<'info>,
#[account(
init_if_needed, // Creates if doesn't exist
payer = merchant,
associated_token::mint = usdc_mint,
associated_token::authority = escrow_authority,
associated_token::token_program = token_program,
)]
pub escrow_usdc: InterfaceAccount<'info, TokenAccount>,
pub usdc_mint: InterfaceAccount<'info, Mint>,
pub token_program: Interface<'info, TokenInterface>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub system_program: Program<'info, System>,
}Key points:
PDA Derivation (from test):
[merchantPoolPda] = PublicKey.findProgramAddressSync(
[Buffer.from("pool"), merchant.publicKey.toBuffer()],
program.programId
);- Seeds:
["pool", merchant_pubkey] - One pool per merchant
- Deterministic address
Escrow Authority PDA (from test):
[escrowAuthorityPda] = PublicKey.findProgramAddressSync(
[Buffer.from("escrow_authority"), merchantPoolPda.toBuffer()],
program.programId
);- Seeds:
["escrow_authority", pool_pubkey] - This PDA will “own” the escrow token account
Escrow Token Account (from test):
escrowUsdc = getAssociatedTokenAddressSync(
usdcMint,
escrowAuthorityPda,
true // allowOwnerOffCurve = true (it's a PDA)
);- Associated Token Account (ATA) for the escrow authority PDA
- Holds the USDC that pays commissions
Why UncheckedAccount for escrow_authority?
- It’s just a PDA, not an initialized account
- Only needs to exist for seeds validation
- Will be the signer for escrow transfers
2. add_affiliate - Registering a New Affiliate
Section titled “2. add_affiliate - Registering a New Affiliate”pub fn add_affiliate(ctx: Context<AddAffiliate>, ref_id: String) -> Result<()>Step 1: Validate ref_id
require!(
ref_id.len() > 0 && ref_id.len() <= 32,
ErrorCode::InvalidRefId
);- From test:
"AFF001"✓ - Empty string → Error
- 33+ characters → Error
Step 2: Initialize affiliate account
let affiliate = &mut ctx.accounts.affiliate_account;
affiliate.pool = ctx.accounts.merchant_pool.key();
affiliate.wallet = ctx.accounts.affiliate_wallet.key();
affiliate.ref_id = ref_id.clone();
affiliate.total_earned = 0;
affiliate.sales_count = 0;
affiliate.is_active = true;
affiliate.bump = ctx.bumps.affiliate_account;
affiliate.created_at = Clock::get()?.unix_timestamp;Step 3: Emit event
emit!(AffiliateAdded {
pool: affiliate.pool,
affiliate: affiliate.key(),
wallet: affiliate.wallet,
ref_id,
timestamp: affiliate.created_at,
});Account Structure:
#[derive(Accounts)]
#[instruction(ref_id: String)] // Access instruction args
pub struct AddAffiliate<'info> {
#[account(
seeds = [b"pool", merchant.key().as_ref()],
bump = merchant_pool.bump,
has_one = merchant @ ErrorCode::Unauthorized // Validates pool.merchant == merchant
)]
pub merchant_pool: Account<'info, MerchantPool>,
#[account(
init,
payer = merchant,
space = 8 + AffiliateAccount::INIT_SPACE,
seeds = [
b"affiliate",
merchant_pool.key().as_ref(),
affiliate_wallet.key().as_ref()
],
bump
)]
pub affiliate_account: Account<'info, AffiliateAccount>,
pub affiliate_wallet: UncheckedAccount<'info>, // Just a Pubkey reference
#[account(mut)]
pub merchant: Signer<'info>,
pub system_program: Program<'info, System>,
}Affiliate PDA Derivation (from test):
[affiliatePda] = PublicKey.findProgramAddressSync(
[
Buffer.from("affiliate"),
merchantPoolPda.toBuffer(),
affiliate.publicKey.toBuffer(),
],
program.programId
);- Seeds:
["affiliate", pool_pubkey, affiliate_wallet_pubkey] - One affiliate account per wallet per pool
- Same person can be affiliate for multiple merchants
Why has_one = merchant?
- Anchor auto-checks:
merchant_pool.merchant == merchant.key() - Prevents random people from adding affiliates to someone else’s pool
@ ErrorCode::Unauthorizedspecifies custom error
3. process_sale - The Core Payment Logic
Section titled “3. process_sale - The Core Payment Logic”pub fn process_sale(ctx: Context<ProcessSale>, sale_amount: u64) -> Result<()>This is the most complex and important function. Let’s break it down completely.
Step 1: Input validation
require!(sale_amount > 0, ErrorCode::InvalidAmount);- From test:
50_000_000(50 USDC)
Step 2: Affiliate status check
let affiliate = &mut ctx.accounts.affiliate_account;
require!(affiliate.is_active, ErrorCode::AffiliateInactive);- If removed, can’t earn commissions
- Prevents paying deactivated affiliates
Step 3: Calculate commission with overflow protection
let pool = &mut ctx.accounts.merchant_pool;
let commission_rate_u64 = pool.commission_rate as u64;
let commission = sale_amount
.checked_mul(commission_rate_u64)
.ok_or(ErrorCode::ArithmeticOverflow)?
.checked_div(10000)
.ok_or(ErrorCode::ArithmeticOverflow)?;Why checked_mul and checked_div?
- Normal
*and/wrap on overflow (security risk!) checked_*returnsNoneon overflowok_or(...)convertsNoneto error
Example calculation (from test):
sale_amount = 50_000_000 (50 USDC)
commission_rate = 1000 (10%)
commission = 50_000_000 × 1000 ÷ 10000
= 50_000_000_000 ÷ 10000
= 5_000_000 (5 USDC)Step 4: Validate commission
require!(commission > 0, ErrorCode::CommissionTooSmall);- Very small sales might round to 0
- Example: 0.001 USDC sale with 1% rate = 0.00001 USDC = 0 (rounded)
Step 5: Check escrow balance
ctx.accounts.escrow_usdc.reload()?;
require!(
ctx.accounts.escrow_usdc.amount >= commission,
ErrorCode::InsufficientEscrowBalance
);reload()fetches latest account data from chain- Prevents paying more than available
- Merchant must maintain adequate escrow balance
Step 6: Prepare PDA signing
let decimals = ctx.accounts.usdc_mint.decimals;
let pool_key = pool.key();
let seeds = &[b"escrow_authority", pool_key.as_ref(), &[pool.escrow_bump]];
let signer_seeds = &[&seeds[..]];Critical concept: PDA Signing
- Normal accounts sign with private keys
- PDAs “sign” by providing their seeds
- Only the program that derived the PDA can use it
Step 7: Transfer commission from escrow to affiliate
token_interface::transfer_checked(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
TransferChecked {
from: ctx.accounts.escrow_usdc.to_account_info(),
mint: ctx.accounts.usdc_mint.to_account_info(),
to: ctx.accounts.affiliate_usdc.to_account_info(),
authority: ctx.accounts.escrow_authority.to_account_info(),
},
signer_seeds,
),
commission,
decimals,
)?;What’s happening:
CpiContext::new_with_signer- Includes PDA seedsfrom: escrow_usdc- Merchant’s escrowto: affiliate_usdc- Affiliate’s token accountauthority: escrow_authority- PDA that “signs”signer_seeds- Proves program controls the PDA
Step 8: Update statistics
affiliate.total_earned = affiliate
.total_earned
.checked_add(commission)
.ok_or(ErrorCode::ArithmeticOverflow)?;
affiliate.sales_count = affiliate
.sales_count
.checked_add(1)
.ok_or(ErrorCode::ArithmeticOverflow)?;
pool.total_volume = pool
.total_volume
.checked_add(sale_amount)
.ok_or(ErrorCode::ArithmeticOverflow)?;
pool.total_commissions_paid = pool
.total_commissions_paid
.checked_add(commission)
.ok_or(ErrorCode::ArithmeticOverflow)?;From test verification:
expect(affiliateAccount.totalEarned.toNumber()).to.equal(5_000_000);
expect(affiliateAccount.salesCount.toNumber()).to.equal(1);Step 9: Emit event
emit!(SaleProcessed {
pool: pool.key(),
affiliate: affiliate.key(),
affiliate_wallet: affiliate.wallet,
sale_amount,
commission,
timestamp: Clock::get()?.unix_timestamp,
});Account Structure:
#[derive(Accounts)]
pub struct ProcessSale<'info> {
#[account(
mut,
seeds = [b"pool", merchant_pool.merchant.as_ref()],
bump = merchant_pool.bump
)]
pub merchant_pool: Account<'info, MerchantPool>,
#[account(
mut,
seeds = [
b"affiliate",
merchant_pool.key().as_ref(),
affiliate_wallet.key().as_ref()
],
bump = affiliate_account.bump,
constraint = affiliate_account.pool == merchant_pool.key() @ ErrorCode::InvalidAffiliate
)]
pub affiliate_account: Account<'info, AffiliateAccount>,
#[account(mut)]
pub affiliate_wallet: UncheckedAccount<'info>,
#[account(
seeds = [b"escrow_authority", merchant_pool.key().as_ref()],
bump = merchant_pool.escrow_bump
)]
pub escrow_authority: UncheckedAccount<'info>,
#[account(
mut,
constraint = escrow_usdc.owner == escrow_authority.key(),
constraint = escrow_usdc.mint == usdc_mint.key()
)]
pub escrow_usdc: InterfaceAccount<'info, TokenAccount>,
#[account(
init_if_needed, // Creates affiliate's USDC account if needed
payer = authority,
associated_token::mint = usdc_mint,
associated_token::authority = affiliate_wallet,
associated_token::token_program = token_program,
)]
pub affiliate_usdc: InterfaceAccount<'info, TokenAccount>,
pub usdc_mint: InterfaceAccount<'info, Mint>,
#[account(mut)]
pub authority: Signer<'info>, // Pays rent if creating affiliate_usdc
pub token_program: Interface<'info, TokenInterface>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub system_program: Program<'info, System>,
}Important: Who signs this transaction?
- From test:
backend.publicKeysigns - Not the merchant, not the affiliate
- Could be a trusted backend server
authorityjust pays rent for account creation if needed
Why init_if_needed for affiliate_usdc?
- New affiliates might not have a USDC token account yet
- Creates it automatically on first payment
authority(backend) pays ~0.002 SOL rent
4. remove_affiliate - Deactivating an Affiliate
Section titled “4. remove_affiliate - Deactivating an Affiliate”pub fn remove_affiliate(ctx: Context<RemoveAffiliate>) -> Result<()> {
let affiliate = &mut ctx.accounts.affiliate_account;
affiliate.is_active = false;
emit!(AffiliateRemoved {
pool: ctx.accounts.merchant_pool.key(),
affiliate: affiliate.key(),
wallet: affiliate.wallet,
timestamp: Clock::get()?.unix_timestamp,
});
Ok(())
}What this does:
- Sets
is_active = false - Does NOT delete the account (preserves history)
- Future
process_salecalls will fail withAffiliateInactive
From test:
const affiliateAccount = await program.account.affiliateAccount.fetch(
affiliatePda
);
expect(affiliateAccount.isActive).to.be.false;Account Structure:
#[derive(Accounts)]
pub struct RemoveAffiliate<'info> {
#[account(
seeds = [b"pool", merchant.key().as_ref()],
bump = merchant_pool.bump,
has_one = merchant @ ErrorCode::Unauthorized
)]
pub merchant_pool: Account<'info, MerchantPool>,
#[account(
mut,
seeds = [
b"affiliate",
merchant_pool.key().as_ref(),
affiliate_wallet.key().as_ref()
],
bump = affiliate_account.bump
)]
pub affiliate_account: Account<'info, AffiliateAccount>,
#[account(mut)]
pub affiliate_wallet: UncheckedAccount<'info>,
pub merchant: Signer<'info>,
}Why not delete the account?
- Preserves earnings history
- Can reactivate later if needed
- Avoid rent reclaim complexities
5. deposit_escrow - Adding Funds
Section titled “5. deposit_escrow - Adding Funds”pub fn deposit_escrow(ctx: Context<DepositEscrow>, amount: u64) -> Result<()> {
require!(amount > 0, ErrorCode::InvalidAmount);
let decimals = ctx.accounts.usdc_mint.decimals;
token_interface::transfer_checked(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
TransferChecked {
from: ctx.accounts.merchant_usdc.to_account_info(),
mint: ctx.accounts.usdc_mint.to_account_info(),
to: ctx.accounts.escrow_usdc.to_account_info(),
authority: ctx.accounts.merchant.to_account_info(),
},
),
amount,
decimals,
)?;
emit!(EscrowDeposited {
pool: ctx.accounts.merchant_pool.key(),
amount,
timestamp: Clock::get()?.unix_timestamp,
});
Ok(())
}What this does:
- Transfers USDC from merchant to escrow
- Simple merchant-signed transfer
- Ensures escrow has funds to pay commissions
From test:
const DEPOSIT = 50_000_000; // 50 USDC
await program.methods
.depositEscrow(new anchor.BN(DEPOSIT))
.accounts({...})
.signers([merchant])
.rpc();6. withdraw_escrow - Removing Funds
Section titled “6. withdraw_escrow - Removing Funds”pub fn withdraw_escrow(ctx: Context<WithdrawEscrow>, amount: u64) -> Result<()> {
require!(amount > 0, ErrorCode::InvalidAmount);
ctx.accounts.escrow_usdc.reload()?;
require!(
ctx.accounts.escrow_usdc.amount >= amount,
ErrorCode::InsufficientEscrowBalance
);
let pool = &ctx.accounts.merchant_pool;
let decimals = ctx.accounts.usdc_mint.decimals;
let pool_key = pool.key();
let seeds = &[b"escrow_authority", pool_key.as_ref(), &[pool.escrow_bump]];
let signer_seeds = &[&seeds[..]];
token_interface::transfer_checked(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
TransferChecked {
from: ctx.accounts.escrow_usdc.to_account_info(),
mint: ctx.accounts.usdc_mint.to_account_info(),
to: ctx.accounts.merchant_usdc.to_account_info(),
authority: ctx.accounts.escrow_authority.to_account_info(),
},
signer_seeds,
),
amount,
decimals,
)?;
emit!(EscrowWithdrawn {
pool: pool.key(),
amount,
timestamp: Clock::get()?.unix_timestamp,
});
Ok(())
}What this does:
- Transfers USDC from escrow back to merchant
- Uses PDA signing (like
process_sale) - Merchant can reclaim unused funds
From test:
const WITHDRAW = 20_000_000; // 20 USDC
await program.methods
.withdrawEscrow(new anchor.BN(WITHDRAW))
.accounts({...})
.signers([merchant])
.rpc();Security & Validation
Section titled “Security & Validation”1. Overflow Protection
Section titled “1. Overflow Protection”Every arithmetic operation uses checked math:
.checked_mul(commission_rate_u64)
.ok_or(ErrorCode::ArithmeticOverflow)?Why?
- Rust’s default
*wraps:u64::MAX + 1 = 0 - Attackers could exploit this to steal funds
checked_*returnsNoneon overflow
2. PDA Security
Section titled “2. PDA Security”Seeds must be deterministic and unique:
seeds = [b"pool", merchant.key().as_ref()]Why this is secure:
- Only one pool per merchant (collision impossible)
- Program controls the PDA (no private key exists)
- Seeds are validated on every transaction
3. Constraint Validation
Section titled “3. Constraint Validation”Account ownership checks:
constraint = escrow_usdc.owner == escrow_authority.key()
constraint = escrow_usdc.mint == usdc_mint.key()Prevents:
- Using wrong token accounts
- Substituting malicious accounts
4. Authorization
Section titled “4. Authorization”Only merchant can manage pool:
has_one = merchant @ ErrorCode::UnauthorizedAnchor auto-validates:
merchant_pool.merchant == merchant.key()- Must also be a
Signer
5. Balance Checks
Section titled “5. Balance Checks”Before transfers:
ctx.accounts.escrow_usdc.reload()?;
require!(
ctx.accounts.escrow_usdc.amount >= commission,
ErrorCode::InsufficientEscrowBalance
);Prevents:
- Overdrawing escrow
- Paying commissions without funds