Skip to content

Contract

  1. Architecture Overview
  2. Imports & Dependencies
  3. Data Structures
  4. Instructions (Functions)
  5. Account Structures
  6. Security & Validation

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

use anchor_lang::prelude::*;

What it includes:

  • Account - Wrapper for deserialized account data
  • Pubkey - 32-byte address type
  • Signer - Account that must sign the transaction
  • System, 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 type
  • TokenAccount - Account holding tokens
  • TokenInterface - Supports both Token and Token-2022 programs
  • TransferChecked - 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

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 bytes

Field-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_pool account
  • 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 bytes

Field-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_usdc token 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: true after add_affiliate, false after remove_affiliate
  • Soft delete (preserves history)

created_at: i64

  • Unix timestamp when affiliate was added
  • Uses Clock::get()?.unix_timestamp

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 → Expects InvalidCommissionRate error
  • 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.bumps contains 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_checked validates 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::Unauthorized specifies custom error

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_* returns None on overflow
  • ok_or(...) converts None to 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 seeds
  • from: escrow_usdc - Merchant’s escrow
  • to: affiliate_usdc - Affiliate’s token account
  • authority: 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.publicKey signs
  • Not the merchant, not the affiliate
  • Could be a trusted backend server
  • authority just 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_sale calls will fail with AffiliateInactive

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

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();

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();

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_* returns None on overflow

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

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

Only merchant can manage pool:

has_one = merchant @ ErrorCode::Unauthorized

Anchor auto-validates:

  • merchant_pool.merchant == merchant.key()
  • Must also be a Signer

Before transfers:

ctx.accounts.escrow_usdc.reload()?;
require!(
    ctx.accounts.escrow_usdc.amount >= commission,
    ErrorCode::InsufficientEscrowBalance
);

Prevents:

  • Overdrawing escrow
  • Paying commissions without funds