Beltsys Labs
Beltsys Labs
Advanced Solana Anchor Rust

Building a Deterministic Wallet Factory on Solana with Anchor

Lucía
Lucía · Marketing & Content
39 min read
Building a Deterministic Wallet Factory on Solana with Anchor

Introduction

Payment processing platforms, exchanges, and custodial services face a common challenge: they need to generate unique deposit addresses for each user, then consolidate received funds into a central treasury. On EVM chains (Ethereum, Polygon, etc.), the standard approach uses CREATE2 or CREATE3 opcodes to deploy lightweight proxy contracts at deterministic addresses. On Solana, we achieve the same result using Program Derived Addresses (PDAs) – and the result is more elegant, cheaper, and arguably more secure.

This tutorial walks through building a complete Wallet Factory program on Solana using the Anchor framework. The program:

  • Deploys deterministic wallet receiver accounts from a 32-byte identifier
  • Sweeps native SOL with basis-point (BPS) distribution to multiple recipients
  • Sweeps SPL tokens with the same BPS distribution model
  • Supports atomic deploy-and-sweep in a single transaction
  • Includes emergency recovery, pause/unpause, and relayer management

By the end, you will have a production-grade Solana program with full TypeScript integration tests. The architecture translates directly from EVM wallet factory patterns, so if you are migrating from Ethereum, this guide bridges the conceptual gap.

Who is this for?

  • Solana developers building payment infrastructure
  • EVM developers migrating wallet factory patterns to Solana
  • Teams building custodial or semi-custodial deposit systems
  • Anyone who wants to understand PDAs, CPIs, and Anchor patterns in depth

Prerequisites

  • Rust and Cargo installed
  • Solana CLI tools (v1.18+)
  • Node.js 18+ and npm
  • Anchor CLI (v0.30.1)
  • Basic familiarity with Solana’s account model

How Solana PDAs Work

The Problem

You need to generate a deposit address for user #47291. On Ethereum, you would deploy a minimal proxy contract at a deterministic address using CREATE2(salt, bytecodeHash). The address is computable off-chain before deployment.

On Solana, there are no “contracts” per se – there are programs (stateless code) and accounts (state). You cannot “deploy a contract per user.” Instead, you derive a Program Derived Address (PDA) that is unique, deterministic, and controlled by your program.

Seeds and Bumps

A PDA is derived from:

  1. Seeds – arbitrary byte arrays you choose (e.g., "wallet_receiver" + wallet_id)
  2. Program ID – the program that “owns” the PDA
  3. Bump – a single byte (0-255) that ensures the derived address falls off the ed25519 curve

The derivation is:

PDA=SHA256("wallet_receiver"wallet_idbumpprogram_id)

Anchor finds the highest valid bump automatically (the “canonical bump”). Because the address is off-curve, no private key exists for it – only the program can sign for this account.

Why PDAs Are Deterministic

Given the same seeds and program ID, you always get the same address. This means:

  • Your backend can compute the deposit address before it exists on-chain
  • Users can send SOL or tokens to the PDA address immediately
  • The program can later “deploy” the account and sweep funds in a single transaction

EVM CREATE2 vs Solana PDA

AspectEVM CREATE2Solana PDA
Determinismkeccak256(0xff, deployer, salt, initCodeHash)SHA256(seeds, bump, programId)
Cost to createDeploy contract (~32,000+ gas)Create account (~0.002 SOL rent)
Code at addressYes (proxy bytecode)No (PDA is just an account)
Can receive before deployYes (ETH only, not ERC-20)Yes (SOL and SPL tokens)
RedeployableOnly with selfdestruct (deprecated)No (account persists)
AuthorityOwner/contract logicProgram that derived it
Off-chain computationethers.getCreate2Address()PublicKey.findProgramAddressSync()
Max per programUnlimitedUnlimited (different seeds)

The key advantage on Solana: PDAs can receive both SOL and SPL tokens before the account is initialized by the program. There is no equivalent limitation to ERC-20 tokens requiring a deployed contract.

Architecture Overview

s"efsFS(eatatPdcacaDsttttA:oeoe)r"ryiy_nitial"+iswrzWRPeaeweaeDelcalcAdlel(/W(leseilBTaRei#:tveaydlutv1_etcpelser_kepetr"ieSltdncoF/_drya1icA/p/tntocRsrh"+ecwyoswrllerWRPeaewaieP)aeDelcayeprlcAdlelenoleseilrt/gei#:tve)rtv2_etaaer_dmr"imdi_n2"+WRPswraeDeaewlcAelcaledlelei#seiltvN:tvee_etrr_"id_N

Two Account Types

FactoryState (singleton PDA) – One per program deployment. Stores the owner, relayer, pause flag, and PDA bump. Seeds: ["factory_state"].

WalletReceiver (per-wallet PDA) – One per deposit address. Stores the wallet_id, parent factory reference, initialization flag, and bump. Seeds: ["wallet_receiver", wallet_id].

Role-Based Access

  • Owner – The deployer. Can pause/unpause the factory, change the relayer, and execute emergency sweeps (bypasses pause).
  • Relayer – An automated backend key. Can deploy wallets and execute regular sweeps. Cannot modify factory state or do emergency sweeps.

This separation means your hot relayer key has limited blast radius if compromised.

Project Setup

Initialize the Anchor Project

anchor init wallet-factory
cd wallet-factory

Cargo.toml

The program’s Cargo.toml defines dependencies on Anchor and the SPL Token library:

[package]
name = "wallet_factory"
version = "0.1.0"
description = "Solana Multi-chain Wallet Factory"
edition = "2021"

[lib]
crate-type = ["cdylib", "lib"]
name = "wallet_factory"

[features]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
cpi = ["no-entrypoint"]
default = []

[dependencies]
anchor-lang = "0.30.1"
anchor-spl = "0.30.1"
solana-program = "~1.18"
winnow = "=0.4.1"        # Fix for dependency issues in some environments

Key points:

  • anchor-lang provides the framework macros (#[program], #[account], #[derive(Accounts)])
  • anchor-spl provides typed wrappers for the SPL Token program (used in token sweeps)
  • solana-program is pinned to ~1.18 for compatibility with the Anchor version
  • crate-type = ["cdylib", "lib"] builds both a shared library (for on-chain) and a Rust library (for tests/CPI)

Anchor.toml

[toolchain]

[features]
resolution = true
skip-lint = false

[programs.localnet]
wallet_factory = "Bv3xMqy2kyu7RX3Vzi9PVgo12UoWcLLhWN71kcmTQRFF"

[registry]
url = "https://api.apr.dev"

[provider]
cluster = "Localnet"
wallet = "~/.config/solana/id.json"

[scripts]
test = "npm run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"

Replace the program ID with the one generated by anchor keys list after your first build.

package.json (Test Dependencies)

{
  "name": "wallet-factory",
  "version": "0.1.0",
  "description": "Solana Multi-chain Wallet Factory Tests",
  "scripts": {
    "lint:fix": "prettier */*.js* */*.ts* --write",
    "lint": "prettier */*.js* */*.ts* --check"
  },
  "dependencies": {
    "@coral-xyz/anchor": "^0.29.0",
    "@solana/spl-token": "^0.3.9",
    "@solana/web3.js": "^1.87.6"
  },
  "devDependencies": {
    "chai": "^4.3.4",
    "mocha": "^9.0.3",
    "ts-mocha": "^10.0.0",
    "typescript": "^5.0.0",
    "prettier": "^2.6.2"
  }
}

Install dependencies:

npm install

File Structure

ptreolesiswgirtntarbramsmidssddeespusla.ototonewweemmean/lmrredrdipeeppeetupesss.u.tleellrr_sat/.rcrioppooggreu-wrstsay__yyeee.sfasil_st__nnlrealoiwooaaccas.clnzalknnyyyrtesel.edd__esot/.lrn__ssrr_res.ssww.yfstrwweer.a.seeeestcreeppstspp__o__strstooyoolk/lk.es.ernrrns.cs.r/rss################PCFMCCSSAAOOUPUIruaorrwwttwwpannoscdeeeeoonnduptgttuaaeemmeeasaeroolttppiirrteugamreeecc--esrmySSooteaeSrFWOPddnnrhterteaaLLeelleetinra-clppyylhototetlwtllafenrrexoeioooStyayprttkyyOoecftc+oyRheLkrtaeporSen++eocsodWttcBsrnprttieasaePsseuyosnsltiSwwwcrbrtlevieeoeky(,eedteevceTtrihppeoyyiRsrvpnePtBSSyeescDrPOPrSteAiSLLycribruvudticetioptrisktiote)oanrnnciscbdoueutcniltoanrsattriuocntss

State Accounts

The state module defines the two on-chain accounts. Every account in Anchor has an 8-byte discriminator (SHA256 of the account name) prepended automatically.

use anchor_lang::prelude::*;

/// Global factory state (singleton PDA)
#[account]
#[derive(Debug)]
pub struct FactoryState {
    /// Owner (can pause/unpause, change relayer, emergency sweep)
    pub owner: Pubkey,
    /// Relayer (can deploy wallets and sweep)
    pub relayer: Pubkey,
    /// Whether the factory is paused
    pub paused: bool,
    /// Bump for the PDA
    pub bump: u8,
}

impl FactoryState {
    pub const SEED: &'static [u8] = b"factory_state";
    pub const LEN: usize = 8 + 32 + 32 + 1 + 1; // discriminator + owner + relayer + paused + bump
}

/// Per-wallet PDA. Stores metadata; the PDA itself receives SOL/tokens.
#[account]
#[derive(Debug)]
pub struct WalletReceiver {
    /// The wallet_id that was used as seed (bytes32 equivalent)
    pub wallet_id: [u8; 32],
    /// Factory that deployed this wallet
    pub factory: Pubkey,
    /// Whether this wallet has been deployed (always true once created)
    pub initialized: bool,
    /// Bump for this PDA
    pub bump: u8,
}

impl WalletReceiver {
    pub const SEED: &'static [u8] = b"wallet_receiver";
    pub const LEN: usize = 8 + 32 + 32 + 1 + 1; // discriminator + wallet_id + factory + initialized + bump
}

Space Calculation Breakdown

For FactoryState:

FieldTypeBytes
Discriminator[u8; 8]8
ownerPubkey32
relayerPubkey32
pausedbool1
bumpu81
Total74

For WalletReceiver:

FieldTypeBytes
Discriminator[u8; 8]8
wallet_id[u8; 32]32
factoryPubkey32
initializedbool1
bumpu81
Total74

Both accounts are exactly 74 bytes. At current rent rates (~6.96 lamports per byte-epoch), the rent-exempt minimum for a 74-byte account is approximately 0.00114 SOL.

Why Store the Bump?

The PDA bump is stored on-chain so that subsequent instructions do not need to recompute it. When you access wallet_receiver.bump, you skip the cost of find_program_address in the runtime. Anchor’s bump = factory_state.bump constraint uses the stored value for verification.

Seed Design

The seed b"factory_state" is a static prefix – only one FactoryState can exist per program. The seed b"wallet_receiver" combined with wallet_id (a 32-byte array) creates a unique PDA per wallet. The wallet_id maps to whatever identifier your backend uses – a UUID, user ID hash, or order number. Using 32 bytes (same as a bytes32 in Solidity) makes cross-chain ID mapping straightforward.

Error Definitions

Custom errors provide clear failure messages and distinct error codes for client-side handling:

use anchor_lang::prelude::*;

#[error_code]
pub enum WalletFactoryError {
    #[msg("Not authorized: signer is not the owner")]
    NotOwner,

    #[msg("Not authorized: signer is not the relayer")]
    NotRelayer,

    #[msg("Factory is currently paused")]
    Paused,

    #[msg("Zero address / pubkey not allowed")]
    ZeroAddress,

    #[msg("Too many recipients (max 5)")]
    TooManyRecipients,

    #[msg("BPS values must sum to exactly 10000")]
    BpsDoNotSum,

    #[msg("Individual BPS value exceeds 10000")]
    BpsOverflow,

    #[msg("No recipients provided")]
    InvalidRecipients,

    #[msg("Nothing to sweep: balance is zero")]
    NothingToSweep,

    #[msg("Arithmetic overflow")]
    Overflow,
}

Each variant becomes an Anchor error code (6000 + index). Your TypeScript client can match on these codes:

ErrorCodeWhen
NotOwner6000Non-owner tries admin operation
NotRelayer6001Non-relayer tries deploy/sweep
Paused6002Any operation while factory is paused
ZeroAddress6003Pubkey::default() passed as recipient or relayer
TooManyRecipients6004More than 5 recipients in a sweep
BpsDoNotSum6005Recipient BPS do not total 10,000
BpsOverflow6006Single recipient BPS exceeds 10,000
InvalidRecipients6007Empty recipients array
NothingToSweep6008Wallet has zero sweepable balance
Overflow6009Arithmetic overflow in distribution

Program Entry Point

The lib.rs file declares the program ID, all instructions, and the Recipient struct used across sweep operations:

use anchor_lang::prelude::*;

pub mod errors;
pub mod instructions;
pub mod state;

use instructions::*;

declare_id!("Bv3xMqy2kyu7RX3Vzi9PVgo12UoWcLLhWN71kcmTQRFF");

#[program]
pub mod wallet_factory {
    use super::*;

    /// Initialize the factory (owner + relayer)
    pub fn initialize(ctx: Context<Initialize>, relayer: Pubkey) -> Result<()> {
        instructions::initialize::handler(ctx, relayer)
    }

    /// Admin: update relayer
    pub fn set_relayer(ctx: Context<SetRelayer>, new_relayer: Pubkey) -> Result<()> {
        instructions::set_relayer::handler(ctx, new_relayer)
    }

    /// Admin: pause the factory
    pub fn pause(ctx: Context<Pause>) -> Result<()> {
        instructions::pause::handler(ctx)
    }

    /// Admin: unpause the factory
    pub fn unpause(ctx: Context<Unpause>) -> Result<()> {
        instructions::unpause::handler(ctx)
    }

    /// Relayer: deploy a new WalletReceiver PDA for a given wallet_id
    pub fn deploy_wallet(ctx: Context<DeployWallet>, wallet_id: [u8; 32]) -> Result<()> {
        instructions::deploy_wallet::handler(ctx, wallet_id)
    }

    /// Relayer: sweep SOL from a wallet receiver PDA to multiple recipients (by BPS)
    pub fn sweep_sol(
        ctx: Context<SweepSol>,
        wallet_id: [u8; 32],
        recipients: Vec<Recipient>,
    ) -> Result<()> {
        instructions::sweep_sol::handler(ctx, wallet_id, recipients)
    }

    /// Relayer: sweep SPL tokens from a wallet receiver PDA to multiple recipients (by BPS)
    pub fn sweep_token(
        ctx: Context<SweepToken>,
        wallet_id: [u8; 32],
        recipients: Vec<Recipient>,
    ) -> Result<()> {
        instructions::sweep_token::handler(ctx, wallet_id, recipients)
    }

    /// Relayer: deploy + sweep SOL atomically
    pub fn deploy_and_sweep_sol(
        ctx: Context<DeployAndSweepSol>,
        wallet_id: [u8; 32],
        recipients: Vec<Recipient>,
    ) -> Result<()> {
        instructions::deploy_and_sweep_sol::handler(ctx, wallet_id, recipients)
    }

    /// Relayer: deploy + sweep SPL token atomically
    pub fn deploy_and_sweep_token(
        ctx: Context<DeployAndSweepToken>,
        wallet_id: [u8; 32],
        recipients: Vec<Recipient>,
    ) -> Result<()> {
        instructions::deploy_and_sweep_token::handler(ctx, wallet_id, recipients)
    }

    /// Owner: emergency sweep SOL to a single destination
    pub fn emergency_sweep_sol(
        ctx: Context<EmergencySweepSol>,
        wallet_id: [u8; 32],
    ) -> Result<()> {
        instructions::emergency_sweep_sol::handler(ctx, wallet_id)
    }

    /// Owner: emergency sweep SPL token to a single destination
    pub fn emergency_sweep_token(
        ctx: Context<EmergencySweepToken>,
        wallet_id: [u8; 32],
    ) -> Result<()> {
        instructions::emergency_sweep_token::handler(ctx, wallet_id)
    }
}

/// Recipient struct (BPS-based distribution)
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct Recipient {
    pub wallet: Pubkey,
    pub bps: u16, // basis points, max 10_000
}

Design Decisions

Thin lib.rs: Each instruction delegates to its own file in the instructions/ module. This keeps the entry point readable and each instruction self-contained with its handler function and Accounts struct.

Recipient at crate root: The Recipient struct is used by six different instructions. Placing it in lib.rs avoids circular imports between instruction modules.

wallet_id as [u8; 32]: A fixed-size byte array matches Solidity’s bytes32 type, making cross-chain ID mapping trivial. Your backend generates a unique 32-byte identifier per deposit address.

BPS (Basis Points): 10,000 BPS = 100%. This allows splits like 70/30 (7000/3000) or 50/25/25 (5000/2500/2500) without floating-point math.

Instructions Module

The module file re-exports all instruction types:

pub mod initialize;
pub mod set_relayer;
pub mod pause;
pub mod unpause;
pub mod deploy_wallet;
pub mod sweep_sol;
pub mod sweep_token;
pub mod deploy_and_sweep_sol;
pub mod deploy_and_sweep_token;
pub mod emergency_sweep_sol;
pub mod emergency_sweep_token;

pub use initialize::*;
pub use set_relayer::*;
pub use pause::*;
pub use unpause::*;
pub use deploy_wallet::*;
pub use sweep_sol::*;
pub use sweep_token::*;
pub use deploy_and_sweep_sol::*;
pub use deploy_and_sweep_token::*;
pub use emergency_sweep_sol::*;
pub use emergency_sweep_token::*;

Core Instructions

Initialize

Creates the singleton FactoryState PDA, setting the caller as the owner and assigning the relayer:

use anchor_lang::prelude::*;
use crate::state::FactoryState;
use crate::errors::WalletFactoryError;

pub fn handler(ctx: Context<Initialize>, relayer: Pubkey) -> Result<()> {
    require!(relayer != Pubkey::default(), WalletFactoryError::ZeroAddress);

    let state = &mut ctx.accounts.factory_state;
    state.owner = ctx.accounts.owner.key();
    state.relayer = relayer;
    state.paused = false;
    state.bump = ctx.bumps.factory_state;

    emit!(RelayerUpdated {
        old_relayer: Pubkey::default(),
        new_relayer: relayer,
    });

    Ok(())
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = owner,
        space = FactoryState::LEN,
        seeds = [FactoryState::SEED],
        bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    #[account(mut)]
    pub owner: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[event]
pub struct RelayerUpdated {
    pub old_relayer: Pubkey,
    pub new_relayer: Pubkey,
}

What happens under the hood:

  1. Anchor derives the PDA from ["factory_state"] + the program ID
  2. It calls system_program::create_account to allocate 74 bytes
  3. The owner pays the rent-exempt lamports
  4. The discriminator is written automatically (first 8 bytes)
  5. Our handler fills in the remaining fields

The init constraint ensures this instruction can only succeed once – calling it again fails because the account already exists.

Deploy Wallet

Creates a new WalletReceiver PDA for a given wallet_id. This is the Solana equivalent of deploying a CREATE2 proxy:

use anchor_lang::prelude::*;
use crate::state::{FactoryState, WalletReceiver};
use crate::errors::WalletFactoryError;

pub fn handler(ctx: Context<DeployWallet>, wallet_id: [u8; 32]) -> Result<()> {
    let state = &ctx.accounts.factory_state;
    require!(!state.paused, WalletFactoryError::Paused);
    require!(ctx.accounts.relayer.key() == state.relayer, WalletFactoryError::NotRelayer);

    let receiver = &mut ctx.accounts.wallet_receiver;
    receiver.wallet_id = wallet_id;
    receiver.factory = ctx.accounts.factory_state.key();
    receiver.initialized = true;
    receiver.bump = ctx.bumps.wallet_receiver;

    emit!(WalletDeployed {
        wallet_id,
        receiver: ctx.accounts.wallet_receiver.key(),
    });

    Ok(())
}

#[derive(Accounts)]
#[instruction(wallet_id: [u8; 32])]
pub struct DeployWallet<'info> {
    #[account(
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    /// WalletReceiver PDA — deterministic from wallet_id (equivalent to CREATE2)
    #[account(
        init,
        payer = relayer,
        space = WalletReceiver::LEN,
        seeds = [WalletReceiver::SEED, &wallet_id],
        bump,
    )]
    pub wallet_receiver: Account<'info, WalletReceiver>,

    #[account(mut)]
    pub relayer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[event]
pub struct WalletDeployed {
    pub wallet_id: [u8; 32],
    pub receiver: Pubkey,
}

Key details:

  • The #[instruction(wallet_id: [u8; 32])] attribute lets Anchor access instruction arguments in the Accounts struct – necessary for using wallet_id in the seeds
  • The relayer pays rent for the new account (~0.00114 SOL)
  • The factory_state is read-only here (no mut) – we only need to check its paused and relayer fields
  • Attempting to deploy the same wallet_id twice fails because the PDA already exists

Sweep SOL

This is the most complex core instruction. It sweeps SOL from a WalletReceiver PDA to multiple recipients based on basis points, while preserving the rent-exempt minimum:

use anchor_lang::prelude::*;
use anchor_lang::system_program;
use crate::state::{FactoryState, WalletReceiver};
use crate::errors::WalletFactoryError;
use crate::Recipient;

/// Helper: validate recipients and compute per-recipient amounts
fn validate_and_compute(balance: u64, recipients: &[Recipient]) -> Result<Vec<u64>> {
    require!(!recipients.is_empty(), WalletFactoryError::InvalidRecipients);
    require!(recipients.len() <= 5, WalletFactoryError::TooManyRecipients);

    let mut total_bps: u64 = 0;
    for r in recipients {
        require!(r.wallet != Pubkey::default(), WalletFactoryError::ZeroAddress);
        require!(r.bps <= 10_000, WalletFactoryError::BpsOverflow);
        total_bps += r.bps as u64;
    }
    require!(total_bps == 10_000, WalletFactoryError::BpsDoNotSum);
    require!(balance > 0, WalletFactoryError::NothingToSweep);

    let mut amounts = Vec::with_capacity(recipients.len());
    let mut distributed: u64 = 0;
    for (i, r) in recipients.iter().enumerate() {
        let amount = if i == recipients.len() - 1 {
            balance - distributed
        } else {
            (balance as u128 * r.bps as u128 / 10_000) as u64
        };
        distributed += amount;
        amounts.push(amount);
    }
    Ok(amounts)
}

pub fn handler(
    ctx: Context<SweepSol>,
    wallet_id: [u8; 32],
    recipients: Vec<Recipient>,
) -> Result<()> {
    let state = &ctx.accounts.factory_state;
    require!(!state.paused, WalletFactoryError::Paused);
    require!(ctx.accounts.relayer.key() == state.relayer, WalletFactoryError::NotRelayer);

    // Sweepable balance = lamports above rent exemption (the PDA data account balance)
    let receiver_info = ctx.accounts.wallet_receiver.to_account_info();
    let rent = Rent::get()?;
    let min_rent = rent.minimum_balance(WalletReceiver::LEN);
    let balance = receiver_info
        .lamports()
        .checked_sub(min_rent)
        .ok_or(WalletFactoryError::NothingToSweep)?;

    let amounts = validate_and_compute(balance, &recipients)?;

    let wallet_id_ref = wallet_id;
    let seeds = &[
        WalletReceiver::SEED,
        &wallet_id_ref,
        &[ctx.accounts.wallet_receiver.bump],
    ];
    let signer_seeds = &[&seeds[..]];

    for (i, r) in recipients.iter().enumerate() {
        if amounts[i] > 0 {
            let ix = anchor_lang::solana_program::system_instruction::transfer(
                &ctx.accounts.wallet_receiver.key(),
                &r.wallet,
                amounts[i],
            );
            anchor_lang::solana_program::program::invoke_signed(
                &ix,
                &[
                    ctx.accounts.wallet_receiver.to_account_info(),
                    ctx.accounts.system_program.to_account_info(),
                ],
                signer_seeds,
            )?;
        }
    }

    emit!(SolSwept {
        wallet_id,
        receiver: ctx.accounts.wallet_receiver.key(),
        total_amount: balance,
        recipient_count: recipients.len() as u8,
    });

    Ok(())
}

#[derive(Accounts)]
#[instruction(wallet_id: [u8; 32])]
pub struct SweepSol<'info> {
    #[account(
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    #[account(
        mut,
        seeds = [WalletReceiver::SEED, &wallet_id],
        bump = wallet_receiver.bump,
    )]
    pub wallet_receiver: Account<'info, WalletReceiver>,

    pub relayer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[event]
pub struct SolSwept {
    pub wallet_id: [u8; 32],
    pub receiver: Pubkey,
    pub total_amount: u64,
    pub recipient_count: u8,
}

Critical concepts in this instruction:

Rent exemption: On Solana, accounts must maintain a minimum lamport balance to avoid being garbage-collected. The sweepable balance is total_lamports - rent_exempt_minimum. You cannot sweep the full balance without closing the account. This is fundamentally different from EVM where ETH balance is fully available.

PDA signing with invoke_signed: PDAs have no private key. To transfer SOL from a PDA, you construct a system instruction and call invoke_signed with the PDA’s seeds + bump. The runtime verifies the seeds derive the correct address and allows the transfer.

Dust-free distribution: The last recipient receives balance - distributed instead of a BPS calculation. This ensures every last lamport is distributed and avoids rounding dust accumulating in the PDA.

u128 intermediate math: The BPS calculation casts to u128 before multiplying: (balance as u128 * bps as u128 / 10_000) as u64. This prevents overflow when balance * bps exceeds u64::MAX (which happens above ~1.8 billion SOL – unlikely but defensive).

Sweep Token

SPL token sweeps follow a similar pattern but use Cross-Program Invocations (CPI) to the Token Program instead of system transfers. Destination token accounts are passed via remaining_accounts:

use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
use crate::state::{FactoryState, WalletReceiver};
use crate::errors::WalletFactoryError;
use crate::Recipient;

pub fn handler(
    ctx: Context<SweepToken>,
    wallet_id: [u8; 32],
    recipients: Vec<Recipient>,
) -> Result<()> {
    let state = &ctx.accounts.factory_state;
    require!(!state.paused, WalletFactoryError::Paused);
    require!(ctx.accounts.relayer.key() == state.relayer, WalletFactoryError::NotRelayer);

    require!(!recipients.is_empty(), WalletFactoryError::InvalidRecipients);
    require!(recipients.len() <= 5, WalletFactoryError::TooManyRecipients);

    let balance = ctx.accounts.source_token_account.amount;
    require!(balance > 0, WalletFactoryError::NothingToSweep);

    let mut total_bps: u64 = 0;
    for r in &recipients {
        require!(r.wallet != Pubkey::default(), WalletFactoryError::ZeroAddress);
        require!(r.bps <= 10_000, WalletFactoryError::BpsOverflow);
        total_bps += r.bps as u64;
    }
    require!(total_bps == 10_000, WalletFactoryError::BpsDoNotSum);

    let wallet_id_ref = wallet_id;
    let seeds = &[
        WalletReceiver::SEED,
        &wallet_id_ref,
        &[ctx.accounts.wallet_receiver.bump],
    ];
    let signer_seeds = &[&seeds[..]];

    let mut distributed: u64 = 0;
    let n = recipients.len();
    for (i, r) in recipients.iter().enumerate() {
        let amount = if i == n - 1 {
            balance - distributed
        } else {
            (balance as u128 * r.bps as u128 / 10_000) as u64
        };
        distributed += amount;

        if amount > 0 {
            // Each recipient_token_account is passed in remaining_accounts
            // Index i maps to remaining_accounts[i]
            let dest_account = &ctx.remaining_accounts[i];

            let cpi_accounts = Transfer {
                from: ctx.accounts.source_token_account.to_account_info(),
                to: dest_account.clone(),
                authority: ctx.accounts.wallet_receiver.to_account_info(),
            };
            let cpi_ctx = CpiContext::new_with_signer(
                ctx.accounts.token_program.to_account_info(),
                cpi_accounts,
                signer_seeds,
            );
            token::transfer(cpi_ctx, amount)?;
        }
    }

    emit!(TokenSwept {
        wallet_id,
        receiver: ctx.accounts.wallet_receiver.key(),
        mint: ctx.accounts.source_token_account.mint,
        total_amount: balance,
        recipient_count: n as u8,
    });

    Ok(())
}

#[derive(Accounts)]
#[instruction(wallet_id: [u8; 32])]
pub struct SweepToken<'info> {
    #[account(
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    #[account(
        seeds = [WalletReceiver::SEED, &wallet_id],
        bump = wallet_receiver.bump,
    )]
    pub wallet_receiver: Account<'info, WalletReceiver>,

    /// Token account owned by the wallet_receiver PDA
    #[account(
        mut,
        constraint = source_token_account.owner == wallet_receiver.key(),
    )]
    pub source_token_account: Account<'info, TokenAccount>,

    pub relayer: Signer<'info>,

    pub token_program: Program<'info, Token>,

    // remaining_accounts: Vec of destination TokenAccounts (one per recipient)
}

#[event]
pub struct TokenSwept {
    pub wallet_id: [u8; 32],
    pub receiver: Pubkey,
    pub mint: Pubkey,
    pub total_amount: u64,
    pub recipient_count: u8,
}

Key differences from SOL sweep:

No rent exemption concern for tokens: The SPL Token account’s balance is the full token amount (rent exemption applies to the SOL in the token account, not the token balance itself). So we sweep the entire source_token_account.amount.

CPI to Token Program: Instead of invoke_signed with a system transfer instruction, we use Anchor’s token::transfer with CpiContext::new_with_signer. This creates a Cross-Program Invocation where the WalletReceiver PDA signs as the token authority.

remaining_accounts for dynamic recipients: Anchor’s Accounts struct requires statically defined accounts. Since the number of recipients varies (1-5), destination token accounts are passed through ctx.remaining_accounts. Index i in the recipients vector maps to remaining_accounts[i].

Source ownership constraint: The constraint = source_token_account.owner == wallet_receiver.key() ensures the token account actually belongs to the PDA. Without this, someone could pass an arbitrary token account.

Deploy and Sweep SOL (Atomic)

This instruction combines wallet deployment and SOL sweep into a single atomic transaction. Critical for the case where you want to deploy and immediately sweep pre-funded lamports:

use anchor_lang::prelude::*;
use crate::state::{FactoryState, WalletReceiver};
use crate::errors::WalletFactoryError;
use crate::Recipient;

pub fn handler(
    ctx: Context<DeployAndSweepSol>,
    wallet_id: [u8; 32],
    recipients: Vec<Recipient>,
) -> Result<()> {
    let state = &ctx.accounts.factory_state;
    require!(!state.paused, WalletFactoryError::Paused);
    require!(ctx.accounts.relayer.key() == state.relayer, WalletFactoryError::NotRelayer);

    // Initialize wallet receiver
    let receiver = &mut ctx.accounts.wallet_receiver;
    receiver.wallet_id = wallet_id;
    receiver.factory = ctx.accounts.factory_state.key();
    receiver.initialized = true;
    receiver.bump = ctx.bumps.wallet_receiver;

    emit!(crate::instructions::deploy_wallet::WalletDeployed {
        wallet_id,
        receiver: ctx.accounts.wallet_receiver.key(),
    });

    // Sweep SOL if any above rent
    let receiver_info = ctx.accounts.wallet_receiver.to_account_info();
    let rent = Rent::get()?;
    let min_rent = rent.minimum_balance(WalletReceiver::LEN);
    let balance = receiver_info.lamports().saturating_sub(min_rent);

    if balance > 0 {
        require!(!recipients.is_empty(), WalletFactoryError::InvalidRecipients);
        require!(recipients.len() <= 5, WalletFactoryError::TooManyRecipients);

        let mut total_bps: u64 = 0;
        for r in &recipients {
            require!(r.wallet != Pubkey::default(), WalletFactoryError::ZeroAddress);
            require!(r.bps <= 10_000, WalletFactoryError::BpsOverflow);
            total_bps += r.bps as u64;
        }
        require!(total_bps == 10_000, WalletFactoryError::BpsDoNotSum);

        let wallet_id_ref = wallet_id;
        let seeds = &[
            WalletReceiver::SEED,
            &wallet_id_ref,
            &[ctx.accounts.wallet_receiver.bump],
        ];
        let signer_seeds = &[&seeds[..]];

        let mut distributed: u64 = 0;
        let n = recipients.len();
        for (i, r) in recipients.iter().enumerate() {
            let amount = if i == n - 1 {
                balance - distributed
            } else {
                (balance as u128 * r.bps as u128 / 10_000) as u64
            };
            distributed += amount;

            if amount > 0 {
                let ix = anchor_lang::solana_program::system_instruction::transfer(
                    &ctx.accounts.wallet_receiver.key(),
                    &r.wallet,
                    amount,
                );
                anchor_lang::solana_program::program::invoke_signed(
                    &ix,
                    &[
                        ctx.accounts.wallet_receiver.to_account_info(),
                        ctx.accounts.system_program.to_account_info(),
                    ],
                    signer_seeds,
                )?;
            }
        }

        emit!(crate::instructions::sweep_sol::SolSwept {
            wallet_id,
            receiver: ctx.accounts.wallet_receiver.key(),
            total_amount: balance,
            recipient_count: n as u8,
        });
    }

    Ok(())
}

#[derive(Accounts)]
#[instruction(wallet_id: [u8; 32])]
pub struct DeployAndSweepSol<'info> {
    #[account(
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    #[account(
        init,
        payer = relayer,
        space = WalletReceiver::LEN,
        seeds = [WalletReceiver::SEED, &wallet_id],
        bump,
    )]
    pub wallet_receiver: Account<'info, WalletReceiver>,

    #[account(mut)]
    pub relayer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

Why atomic matters:

On Solana, you can send SOL to a PDA address before the account is initialized by the program. The lamports sit at that address. When deploy_and_sweep_sol runs:

  1. The init constraint creates the account, absorbing the pre-funded lamports into the new account balance
  2. The handler immediately sweeps any balance above rent exemption

This means a user can deposit SOL, and your backend can deploy + sweep in one transaction – one signature, one block confirmation. In the EVM world, this would require two transactions (deploy proxy, then call sweep).

saturating_sub vs checked_sub: The atomic version uses saturating_sub instead of checked_sub. If the PDA was just created with exactly the rent amount (no pre-funding), balance becomes 0 and the sweep block is skipped gracefully. The standalone sweep_sol uses checked_sub and returns an error because calling sweep with nothing to sweep is unexpected.

Deploy and Sweep Token (Atomic)

The SPL token version of the atomic deploy-and-sweep:

use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
use crate::state::{FactoryState, WalletReceiver};
use crate::errors::WalletFactoryError;
use crate::Recipient;

pub fn handler(
    ctx: Context<DeployAndSweepToken>,
    wallet_id: [u8; 32],
    recipients: Vec<Recipient>,
) -> Result<()> {
    let state = &ctx.accounts.factory_state;
    require!(!state.paused, WalletFactoryError::Paused);
    require!(ctx.accounts.relayer.key() == state.relayer, WalletFactoryError::NotRelayer);

    // Initialize wallet receiver
    let receiver = &mut ctx.accounts.wallet_receiver;
    receiver.wallet_id = wallet_id;
    receiver.factory = ctx.accounts.factory_state.key();
    receiver.initialized = true;
    receiver.bump = ctx.bumps.wallet_receiver;

    emit!(crate::instructions::deploy_wallet::WalletDeployed {
        wallet_id,
        receiver: ctx.accounts.wallet_receiver.key(),
    });

    let balance = ctx.accounts.source_token_account.amount;
    if balance > 0 {
        require!(!recipients.is_empty(), WalletFactoryError::InvalidRecipients);
        require!(recipients.len() <= 5, WalletFactoryError::TooManyRecipients);

        let mut total_bps: u64 = 0;
        for r in &recipients {
            require!(r.wallet != Pubkey::default(), WalletFactoryError::ZeroAddress);
            require!(r.bps <= 10_000, WalletFactoryError::BpsOverflow);
            total_bps += r.bps as u64;
        }
        require!(total_bps == 10_000, WalletFactoryError::BpsDoNotSum);

        let wallet_id_ref = wallet_id;
        let seeds = &[
            WalletReceiver::SEED,
            &wallet_id_ref,
            &[ctx.accounts.wallet_receiver.bump],
        ];
        let signer_seeds = &[&seeds[..]];

        let mut distributed: u64 = 0;
        let n = recipients.len();
        for (i, r) in recipients.iter().enumerate() {
            let amount = if i == n - 1 {
                balance - distributed
            } else {
                (balance as u128 * r.bps as u128 / 10_000) as u64
            };
            distributed += amount;

            if amount > 0 {
                let dest = &ctx.remaining_accounts[i];
                let cpi_accounts = Transfer {
                    from: ctx.accounts.source_token_account.to_account_info(),
                    to: dest.clone(),
                    authority: ctx.accounts.wallet_receiver.to_account_info(),
                };
                let cpi_ctx = CpiContext::new_with_signer(
                    ctx.accounts.token_program.to_account_info(),
                    cpi_accounts,
                    signer_seeds,
                );
                token::transfer(cpi_ctx, amount)?;
            }
        }

        emit!(crate::instructions::sweep_token::TokenSwept {
            wallet_id,
            receiver: ctx.accounts.wallet_receiver.key(),
            mint: ctx.accounts.source_token_account.mint,
            total_amount: balance,
            recipient_count: n as u8,
        });
    }

    Ok(())
}

#[derive(Accounts)]
#[instruction(wallet_id: [u8; 32])]
pub struct DeployAndSweepToken<'info> {
    #[account(
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    #[account(
        init,
        payer = relayer,
        space = WalletReceiver::LEN,
        seeds = [WalletReceiver::SEED, &wallet_id],
        bump,
    )]
    pub wallet_receiver: Account<'info, WalletReceiver>,

    #[account(
        mut,
        constraint = source_token_account.owner == wallet_receiver.key(),
    )]
    pub source_token_account: Account<'info, TokenAccount>,

    #[account(mut)]
    pub relayer: Signer<'info>,

    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>,

    // remaining_accounts: destination TokenAccounts (one per recipient)
}

Token pre-funding flow: For SPL tokens, the flow requires that someone creates an Associated Token Account (ATA) for the PDA before sending tokens. The ATA address is also deterministic (derived from the PDA address + mint address), so your backend can compute it and display it to the user. The deploy_and_sweep_token instruction then deploys the WalletReceiver and sweeps the token balance in one shot.

Emergency and Admin Instructions

Emergency Sweep SOL

The owner can recover all sweepable SOL from any wallet to a single destination. This bypasses the pause check and does not require the relayer:

use anchor_lang::prelude::*;
use crate::state::{FactoryState, WalletReceiver};
use crate::errors::WalletFactoryError;

pub fn handler(ctx: Context<EmergencySweepSol>, wallet_id: [u8; 32]) -> Result<()> {
    let state = &ctx.accounts.factory_state;
    require!(ctx.accounts.owner.key() == state.owner, WalletFactoryError::NotOwner);

    let receiver_info = ctx.accounts.wallet_receiver.to_account_info();
    let rent = Rent::get()?;
    let min_rent = rent.minimum_balance(WalletReceiver::LEN);
    let balance = receiver_info
        .lamports()
        .checked_sub(min_rent)
        .ok_or(WalletFactoryError::NothingToSweep)?;

    require!(balance > 0, WalletFactoryError::NothingToSweep);

    let wallet_id_ref = wallet_id;
    let seeds = &[
        WalletReceiver::SEED,
        &wallet_id_ref,
        &[ctx.accounts.wallet_receiver.bump],
    ];
    let signer_seeds = &[&seeds[..]];

    let ix = anchor_lang::solana_program::system_instruction::transfer(
        &ctx.accounts.wallet_receiver.key(),
        &ctx.accounts.destination.key(),
        balance,
    );
    anchor_lang::solana_program::program::invoke_signed(
        &ix,
        &[
            ctx.accounts.wallet_receiver.to_account_info(),
            ctx.accounts.destination.to_account_info(),
            ctx.accounts.system_program.to_account_info(),
        ],
        signer_seeds,
    )?;

    emit!(EmergencySolSwept {
        wallet_id,
        receiver: ctx.accounts.wallet_receiver.key(),
        destination: ctx.accounts.destination.key(),
        amount: balance,
    });

    Ok(())
}

#[derive(Accounts)]
#[instruction(wallet_id: [u8; 32])]
pub struct EmergencySweepSol<'info> {
    #[account(
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    #[account(
        mut,
        seeds = [WalletReceiver::SEED, &wallet_id],
        bump = wallet_receiver.bump,
    )]
    pub wallet_receiver: Account<'info, WalletReceiver>,

    /// Destination to receive all SOL
    #[account(mut)]
    pub destination: SystemAccount<'info>,

    pub owner: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[event]
pub struct EmergencySolSwept {
    pub wallet_id: [u8; 32],
    pub receiver: Pubkey,
    pub destination: Pubkey,
    pub amount: u64,
}

No pause check: Emergency sweeps intentionally skip require!(!state.paused, ...). The scenario: you detect the relayer key is compromised, pause the factory, then use the owner key to evacuate funds. If emergency sweep respected the pause, you would have to unpause (re-exposing the relayer attack vector) to recover funds.

SystemAccount for destination: Unlike regular sweeps where recipients are passed as Pubkey in the Recipient struct, the emergency destination is a validated SystemAccount. This is a convenience – the destination just needs to be a valid system account, not a program-owned account.

Emergency Sweep Token

use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
use crate::state::{FactoryState, WalletReceiver};
use crate::errors::WalletFactoryError;

pub fn handler(ctx: Context<EmergencySweepToken>, wallet_id: [u8; 32]) -> Result<()> {
    let state = &ctx.accounts.factory_state;
    require!(ctx.accounts.owner.key() == state.owner, WalletFactoryError::NotOwner);

    let balance = ctx.accounts.source_token_account.amount;
    require!(balance > 0, WalletFactoryError::NothingToSweep);

    let wallet_id_ref = wallet_id;
    let seeds = &[
        WalletReceiver::SEED,
        &wallet_id_ref,
        &[ctx.accounts.wallet_receiver.bump],
    ];
    let signer_seeds = &[&seeds[..]];

    let cpi_accounts = Transfer {
        from: ctx.accounts.source_token_account.to_account_info(),
        to: ctx.accounts.destination_token_account.to_account_info(),
        authority: ctx.accounts.wallet_receiver.to_account_info(),
    };
    let cpi_ctx = CpiContext::new_with_signer(
        ctx.accounts.token_program.to_account_info(),
        cpi_accounts,
        signer_seeds,
    );
    token::transfer(cpi_ctx, balance)?;

    emit!(EmergencyTokenSwept {
        wallet_id,
        receiver: ctx.accounts.wallet_receiver.key(),
        mint: ctx.accounts.source_token_account.mint,
        destination: ctx.accounts.destination_token_account.key(),
        amount: balance,
    });

    Ok(())
}

#[derive(Accounts)]
#[instruction(wallet_id: [u8; 32])]
pub struct EmergencySweepToken<'info> {
    #[account(
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    #[account(
        seeds = [WalletReceiver::SEED, &wallet_id],
        bump = wallet_receiver.bump,
    )]
    pub wallet_receiver: Account<'info, WalletReceiver>,

    #[account(
        mut,
        constraint = source_token_account.owner == wallet_receiver.key(),
    )]
    pub source_token_account: Account<'info, TokenAccount>,

    /// Destination token account (same mint, any owner)
    #[account(mut)]
    pub destination_token_account: Account<'info, TokenAccount>,

    pub owner: Signer<'info>,

    pub token_program: Program<'info, Token>,
}

#[event]
pub struct EmergencyTokenSwept {
    pub wallet_id: [u8; 32],
    pub receiver: Pubkey,
    pub mint: Pubkey,
    pub destination: Pubkey,
    pub amount: u64,
}

Set Relayer

Updates the relayer public key. Only the owner can call this:

use anchor_lang::prelude::*;
use crate::state::FactoryState;
use crate::errors::WalletFactoryError;

pub fn handler(ctx: Context<SetRelayer>, new_relayer: Pubkey) -> Result<()> {
    require!(new_relayer != Pubkey::default(), WalletFactoryError::ZeroAddress);

    let state = &mut ctx.accounts.factory_state;
    let old_relayer = state.relayer;
    state.relayer = new_relayer;

    emit!(RelayerUpdated {
        old_relayer,
        new_relayer,
    });

    Ok(())
}

#[derive(Accounts)]
pub struct SetRelayer<'info> {
    #[account(
        mut,
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
        has_one = owner,
    )]
    pub factory_state: Account<'info, FactoryState>,
    pub owner: Signer<'info>,
}

#[event]
pub struct RelayerUpdated {
    pub old_relayer: Pubkey,
    pub new_relayer: Pubkey,
}

has_one = owner constraint: This Anchor constraint automatically checks that factory_state.owner == owner.key(). It is equivalent to the manual require!(ctx.accounts.owner.key() == state.owner, ...) but more idiomatic. Use has_one when the field name in the account struct matches the account name in the Accounts struct.

Pause and Unpause

Simple toggle operations gated by has_one = owner:

use anchor_lang::prelude::*;
use crate::state::FactoryState;

pub fn handler(ctx: Context<Pause>) -> Result<()> {
    let state = &mut ctx.accounts.factory_state;
    state.paused = true;
    Ok(())
}

#[derive(Accounts)]
pub struct Pause<'info> {
    #[account(
        mut,
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
        has_one = owner,
    )]
    pub factory_state: Account<'info, FactoryState>,
    pub owner: Signer<'info>,
}
use anchor_lang::prelude::*;
use crate::state::FactoryState;

pub fn handler(ctx: Context<Unpause>) -> Result<()> {
    let state = &mut ctx.accounts.factory_state;
    state.paused = false;
    Ok(())
}

#[derive(Accounts)]
pub struct Unpause<'info> {
    #[account(
        mut,
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
        has_one = owner,
    )]
    pub factory_state: Account<'info, FactoryState>,
    pub owner: Signer<'info>,
}

These are intentionally simple. In production, you might add:

  • A timelock delay for unpausing
  • Event emissions for pause/unpause
  • A counter tracking how many times the factory has been paused (for monitoring)

Key Solana Concepts Explained

Rent Exemption

Every Solana account must maintain a minimum SOL balance based on its data size. This prevents network spam and ensures validators are compensated for storing state.

For our 74-byte WalletReceiver account, the rent-exempt minimum is approximately 0.00114 SOL (1,141,440 lamports at current rates). This means:

  • If a user deposits 1 SOL into a wallet receiver PDA, the sweepable balance is ~0.99886 SOL
  • The rent-exempt minimum stays in the PDA forever (unless the account is closed)
  • Your backend should account for this when displaying available balances

The formula is: sweepable = total_lamports - rent.minimum_balance(account_size)

This is a fundamental difference from EVM. On Ethereum, you can sweep the entire ETH balance from a contract. On Solana, you must leave rent behind.

PDA Signing with Seeds

A PDA has no private key. So how does it “sign” transactions? It does not, in the traditional sense. Instead:

  1. Your program constructs a transfer instruction specifying the PDA as the source
  2. It calls invoke_signed with the seeds that derive the PDA
  3. The Solana runtime recomputes the PDA from those seeds + program ID
  4. If the result matches the account in the instruction, the transfer is authorized
let seeds = &[
    WalletReceiver::SEED,            // b"wallet_receiver"
    &wallet_id_ref,                   // [u8; 32]
    &[ctx.accounts.wallet_receiver.bump],  // canonical bump
];
let signer_seeds = &[&seeds[..]];

// This invoke_signed lets the PDA "sign" the transfer
anchor_lang::solana_program::program::invoke_signed(
    &transfer_instruction,
    &[source_account, destination_account, system_program],
    signer_seeds,
)?;

This is cryptographically secure: only the program that derived the PDA can produce valid signer seeds for it. No other program can sign for your PDA.

Cross-Program Invocation (CPI) for Token Transfers

Native SOL transfers use the System Program. SPL token transfers use the Token Program. To transfer tokens from a PDA, you perform a CPI:

let cpi_accounts = Transfer {
    from: source_token_account.to_account_info(),
    to: destination_token_account.to_account_info(),
    authority: wallet_receiver.to_account_info(),  // PDA is the authority
};
let cpi_ctx = CpiContext::new_with_signer(
    token_program.to_account_info(),
    cpi_accounts,
    signer_seeds,  // PDA seeds for signing
);
token::transfer(cpi_ctx, amount)?;

Anchor’s CpiContext::new_with_signer wraps the low-level invoke_signed call into a typed interface. The Token Program verifies that the authority (our PDA) signed the transaction using the provided seeds.

remaining_accounts for Dynamic Recipient Lists

Anchor’s #[derive(Accounts)] struct is static – you define all accounts at compile time. But token sweeps need a variable number of destination token accounts (1-5). The solution is remaining_accounts:

// In the Accounts struct:
// remaining_accounts: destination TokenAccounts (one per recipient)

// In the handler:
let dest_account = &ctx.remaining_accounts[i];

The client passes these extra accounts when building the transaction:

await program.methods
  .sweepToken([...walletId], recipients)
  .accounts({ /* ... static accounts ... */ })
  .remainingAccounts([
    { pubkey: destTokenAccount1, isSigner: false, isWritable: true },
    { pubkey: destTokenAccount2, isSigner: false, isWritable: true },
  ])
  .signers([relayer])
  .rpc();

This pattern is common in Solana programs that need to handle variable-length account lists. The trade-off is that remaining_accounts are untyped AccountInfo references – you lose Anchor’s automatic deserialization and constraint checking.

Integration Tests

The complete test suite validates all core flows using Anchor’s TypeScript testing framework:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { WalletFactory } from "../target/types/wallet_factory";
import {
  PublicKey,
  Keypair,
  SystemProgram,
  LAMPORTS_PER_SOL,
} from "@solana/web3.js";
import {
  createMint,
  getOrCreateAssociatedTokenAccount,
  mintTo,
  TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import { assert } from "chai";

describe("wallet_factory", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);
  const program = anchor.workspace.WalletFactory as Program<WalletFactory>;

  const owner = provider.wallet;
  const relayer = Keypair.generate();
  const recipient1 = Keypair.generate();
  const recipient2 = Keypair.generate();

  // Derive factory state PDA
  const [factoryStatePDA] = PublicKey.findProgramAddressSync(
    [Buffer.from("factory_state")],
    program.programId
  );

  // Helper: derive wallet receiver PDA from wallet_id
  function walletReceiverPDA(walletId: Buffer): [PublicKey, number] {
    return PublicKey.findProgramAddressSync(
      [Buffer.from("wallet_receiver"), walletId],
      program.programId
    );
  }

  before(async () => {
    // Airdrop to relayer for fees
    await provider.connection.confirmTransaction(
      await provider.connection.requestAirdrop(relayer.publicKey, 2 * LAMPORTS_PER_SOL)
    );
  });

  // ─── Initialize ──────────────────────────────────────────────────────────────

  it("Initializes the factory", async () => {
    await program.methods
      .initialize(relayer.publicKey)
      .accounts({
        factoryState: factoryStatePDA,
        owner: owner.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .rpc();

    const state = await program.account.factoryState.fetch(factoryStatePDA);
    assert.equal(state.owner.toBase58(), owner.publicKey.toBase58());
    assert.equal(state.relayer.toBase58(), relayer.publicKey.toBase58());
    assert.equal(state.paused, false);
  });

  // ─── Deploy Wallet ────────────────────────────────────────────────────────────

  it("Deploys a wallet receiver", async () => {
    const walletId = Buffer.alloc(32);
    walletId.write("test-wallet-001");
    const [receiverPDA] = walletReceiverPDA(walletId);

    await program.methods
      .deployWallet([...walletId])
      .accounts({
        factoryState: factoryStatePDA,
        walletReceiver: receiverPDA,
        relayer: relayer.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .signers([relayer])
      .rpc();

    const receiver = await program.account.walletReceiver.fetch(receiverPDA);
    assert.equal(receiver.initialized, true);
    assert.deepEqual(receiver.walletId, [...walletId]);
  });

  // ─── Sweep SOL ────────────────────────────────────────────────────────────────

  it("Sweeps SOL from a wallet receiver (50/50 split)", async () => {
    const walletId = Buffer.alloc(32);
    walletId.write("test-wallet-sol");
    const [receiverPDA] = walletReceiverPDA(walletId);

    // Deploy wallet
    await program.methods
      .deployWallet([...walletId])
      .accounts({
        factoryState: factoryStatePDA,
        walletReceiver: receiverPDA,
        relayer: relayer.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .signers([relayer])
      .rpc();

    // Fund the PDA with SOL (direct transfer)
    const fundTx = new anchor.web3.Transaction().add(
      SystemProgram.transfer({
        fromPubkey: owner.publicKey,
        toPubkey: receiverPDA,
        lamports: LAMPORTS_PER_SOL,
      })
    );
    await provider.sendAndConfirm(fundTx);

    const r1Before = await provider.connection.getBalance(recipient1.publicKey);
    const r2Before = await provider.connection.getBalance(recipient2.publicKey);

    const recipients = [
      { wallet: recipient1.publicKey, bps: 5000 },
      { wallet: recipient2.publicKey, bps: 5000 },
    ];

    await program.methods
      .sweepSol([...walletId], recipients)
      .accounts({
        factoryState: factoryStatePDA,
        walletReceiver: receiverPDA,
        relayer: relayer.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .signers([relayer])
      .rpc();

    const r1After = await provider.connection.getBalance(recipient1.publicKey);
    const r2After = await provider.connection.getBalance(recipient2.publicKey);

    // Each should receive ~0.5 SOL
    assert.approximately(r1After - r1Before, 0.5 * LAMPORTS_PER_SOL, 1000);
    assert.approximately(r2After - r2Before, 0.5 * LAMPORTS_PER_SOL, 1000);
  });

  // ─── Deploy + Sweep SOL Atomically ───────────────────────────────────────────

  it("Deploys and sweeps SOL atomically", async () => {
    const walletId = Buffer.alloc(32);
    walletId.write("test-atomic-sol");
    const [receiverPDA] = walletReceiverPDA(walletId);

    // Pre-fund the PDA address (it doesn't exist yet as a program account,
    // but we can send lamports to it — they'll be there when init runs)
    const fundTx = new anchor.web3.Transaction().add(
      SystemProgram.transfer({
        fromPubkey: owner.publicKey,
        toPubkey: receiverPDA,
        lamports: LAMPORTS_PER_SOL,
      })
    );
    await provider.sendAndConfirm(fundTx);

    const recipients = [
      { wallet: recipient1.publicKey, bps: 7000 },
      { wallet: recipient2.publicKey, bps: 3000 },
    ];

    await program.methods
      .deployAndSweepSol([...walletId], recipients)
      .accounts({
        factoryState: factoryStatePDA,
        walletReceiver: receiverPDA,
        relayer: relayer.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .signers([relayer])
      .rpc();

    const receiver = await program.account.walletReceiver.fetch(receiverPDA);
    assert.equal(receiver.initialized, true);
  });

  // ─── Emergency Sweep SOL ─────────────────────────────────────────────────────

  it("Emergency sweeps SOL (owner only)", async () => {
    const walletId = Buffer.alloc(32);
    walletId.write("test-emergency");
    const [receiverPDA] = walletReceiverPDA(walletId);

    await program.methods
      .deployWallet([...walletId])
      .accounts({
        factoryState: factoryStatePDA,
        walletReceiver: receiverPDA,
        relayer: relayer.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .signers([relayer])
      .rpc();

    const fundTx = new anchor.web3.Transaction().add(
      SystemProgram.transfer({
        fromPubkey: owner.publicKey,
        toPubkey: receiverPDA,
        lamports: LAMPORTS_PER_SOL,
      })
    );
    await provider.sendAndConfirm(fundTx);

    const emergency = Keypair.generate();

    await program.methods
      .emergencySweepSol([...walletId])
      .accounts({
        factoryState: factoryStatePDA,
        walletReceiver: receiverPDA,
        destination: emergency.publicKey,
        owner: owner.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .rpc();

    const destBalance = await provider.connection.getBalance(emergency.publicKey);
    assert.isAbove(destBalance, 0);
  });

  // ─── Pause / Unpause ─────────────────────────────────────────────────────────

  it("Pauses and unpauses the factory", async () => {
    await program.methods
      .pause()
      .accounts({ factoryState: factoryStatePDA, owner: owner.publicKey })
      .rpc();

    let state = await program.account.factoryState.fetch(factoryStatePDA);
    assert.equal(state.paused, true);

    await program.methods
      .unpause()
      .accounts({ factoryState: factoryStatePDA, owner: owner.publicKey })
      .rpc();

    state = await program.account.factoryState.fetch(factoryStatePDA);
    assert.equal(state.paused, false);
  });
});

Test Walkthrough

Setup: The test creates a provider (connection + wallet), the program interface, and keypairs for the relayer and two recipients. The before hook airdrops 2 SOL to the relayer so it can pay transaction fees and account rent.

PDA derivation: The helper walletReceiverPDA uses PublicKey.findProgramAddressSync to compute the same address the on-chain program will derive. This is the key to deterministic addressing – your off-chain code and on-chain program agree on the address before it exists.

wallet_id as Buffer: The 32-byte wallet_id is created as a zero-filled Buffer with a human-readable prefix written in. In production, you would use a UUID or hash. The spread operator [...walletId] converts it to a number[] that Anchor’s serializer expects.

Funding before deploy: The atomic sweep test demonstrates pre-funding: SOL is sent to the PDA address before the WalletReceiver account is created. When deploy_and_sweep_sol runs, the init constraint creates the account (inheriting the pre-funded lamports), and the handler immediately sweeps them.

Balance assertions: assert.approximately allows a tolerance of 1000 lamports (~0.000001 SOL) to account for potential rounding in the BPS distribution.

Running the Tests

# Start local validator
solana-test-validator

# In another terminal
anchor build
anchor deploy
anchor test

Or for a single command that handles everything:

anchor test

Generating Wallet Addresses Off-Chain

Your backend needs to compute deposit addresses without interacting with the blockchain. Here is how to derive any wallet receiver address:

import { PublicKey } from "@solana/web3.js";

const PROGRAM_ID = new PublicKey("Bv3xMqy2kyu7RX3Vzi9PVgo12UoWcLLhWN71kcmTQRFF");

/**
 * Derive the wallet receiver PDA for a given wallet ID.
 * Returns the same address the on-chain program will use.
 */
function deriveWalletAddress(walletId: string | Buffer): PublicKey {
  const walletIdBuffer = typeof walletId === "string"
    ? Buffer.alloc(32, 0).fill(walletId, 0, Math.min(walletId.length, 32))
    : walletId;

  const [pda] = PublicKey.findProgramAddressSync(
    [Buffer.from("wallet_receiver"), walletIdBuffer],
    PROGRAM_ID
  );

  return pda;
}

// Example: generate a deposit address for user "user-47291"
const walletId = Buffer.alloc(32);
walletId.write("user-47291");

const depositAddress = deriveWalletAddress(walletId);
console.log(`Deposit address: ${depositAddress.toBase58()}`);
// Output: Deposit address: 7xKX...deterministic...address

For SPL Token Deposits

To receive SPL tokens, the user needs the Associated Token Account (ATA) address for the PDA:

import { getAssociatedTokenAddressSync } from "@solana/spl-token";

const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");

const walletPDA = deriveWalletAddress(walletId);
const tokenDepositAddress = getAssociatedTokenAddressSync(
  USDC_MINT,
  walletPDA,
  true  // allowOwnerOffCurve = true (required for PDAs)
);

console.log(`USDC deposit address: ${tokenDepositAddress.toBase58()}`);

The allowOwnerOffCurve = true parameter is essential because the PDA owner is off the ed25519 curve. Without it, the function throws an error.

Batch Address Generation

For generating thousands of deposit addresses:

function generateDepositAddresses(
  userIds: string[],
  mint?: PublicKey
): Map<string, { sol: PublicKey; token?: PublicKey }> {
  const result = new Map();

  for (const userId of userIds) {
    const walletId = Buffer.alloc(32);
    walletId.write(userId);

    const solAddress = deriveWalletAddress(walletId);
    const entry: { sol: PublicKey; token?: PublicKey } = { sol: solAddress };

    if (mint) {
      entry.token = getAssociatedTokenAddressSync(mint, solAddress, true);
    }

    result.set(userId, entry);
  }

  return result;
}

// Generate 10,000 deposit addresses in milliseconds
const userIds = Array.from({ length: 10000 }, (_, i) => `user-${i}`);
const addresses = generateDepositAddresses(userIds, USDC_MINT);

findProgramAddressSync is a pure hash computation – no RPC calls. Generating 10,000 addresses takes under a second on modern hardware.

Security Considerations

Access Control

The program enforces two-tier access control:

OperationRequired SignerChecks Pause?
initializeOwner (implicit, first call only)No
deploy_walletRelayerYes
sweep_solRelayerYes
sweep_tokenRelayerYes
deploy_and_sweep_solRelayerYes
deploy_and_sweep_tokenRelayerYes
emergency_sweep_solOwnerNo
emergency_sweep_tokenOwnerNo
set_relayerOwnerNo
pauseOwnerNo
unpauseOwnerNo

If the relayer key is compromised:

  1. Call pause() – immediately blocks all relayer operations
  2. Call set_relayer(new_key) – rotate to a fresh key
  3. Use emergency_sweep_sol / emergency_sweep_token to recover any at-risk funds
  4. Call unpause() – resume operations with the new relayer

Rent Exemption Guarantees

The program never allows sweeping below the rent-exempt minimum. This is enforced at two levels:

  1. In sweep_sol: checked_sub(min_rent) returns an error if lamports are below rent
  2. In deploy_and_sweep_sol: saturating_sub(min_rent) returns 0 (skip sweep) if at minimum

Without this, the Solana runtime would garbage-collect the account, permanently losing the PDA and any future deposits to that address.

PDA Validation

Every instruction validates PDAs through Anchor’s seed constraints:

#[account(
    seeds = [WalletReceiver::SEED, &wallet_id],
    bump = wallet_receiver.bump,
)]
pub wallet_receiver: Account<'info, WalletReceiver>,

This ensures:

  • The wallet_receiver account was created by this program (discriminator check)
  • The account’s address matches the expected PDA for the given wallet_id (seed check)
  • The stored bump matches the canonical bump (bump check)

An attacker cannot substitute a different account or a PDA from another program.

BPS Validation

Every sweep instruction validates that:

  • At least 1 recipient is provided
  • No more than 5 recipients (limits compute usage and transaction size)
  • No recipient has Pubkey::default() (the zero address)
  • No individual BPS exceeds 10,000
  • Total BPS sums to exactly 10,000

The 5-recipient limit is a practical constraint. Each recipient requires an additional invoke_signed call (for SOL) or CPI (for tokens). Solana transactions have a compute budget of 200,000 compute units by default – 5 recipients stays well within this limit.

Comparison with EVM Security Model

ConcernEVM Wallet FactorySolana Wallet Factory
ReentrancyMajor risk; use ReentrancyGuardNot applicable (no callbacks in system_program::transfer)
Front-runningMEV bots can front-run deploysValidators can reorder but PDAs are not race-sensitive
Proxy upgrade riskProxy pattern introduces upgrade vectorsNo proxies; program upgrades are separate from data
Selfdestruct attacksCREATE2 + selfdestruct redeployNot possible; PDA accounts persist
Token approval exploitsapprove/transferFrom patternPDA is the authority; no approval needed
Gas griefingRecipient contract can consume gasRecipient accounts are passive; no callback

Solana’s account model eliminates several attack vectors that plague EVM wallet factories:

  • No reentrancy: system transfers and SPL token transfers do not execute arbitrary code on the recipient
  • No approval chains: the PDA directly owns the token account and is the transfer authority
  • No self-destruct redeploy: once a PDA account exists, it cannot be destroyed and recreated with different state

Conclusion

What We Built

A complete deterministic wallet factory on Solana that:

  • Creates unique deposit addresses from a 32-byte identifier using PDAs
  • Distributes received SOL to multiple recipients based on basis points
  • Distributes received SPL tokens using CPI to the Token Program
  • Supports atomic deploy-and-sweep for both SOL and SPL tokens
  • Includes emergency recovery bypassing the pause mechanism
  • Enforces two-tier access control (owner / relayer)
  • Provides TypeScript integration tests and off-chain address derivation

Key Differences from EVM

  1. PDAs replace CREATE2: Same determinism, no bytecode deployment, lower cost
  2. Rent exemption: You cannot sweep 100% of SOL – the rent minimum stays locked
  3. No reentrancy risk: System transfers and token CPI do not callback into your program
  4. Account model: State is in accounts, not contract storage slots
  5. Explicit account passing: Every account must be declared in the transaction
  6. remaining_accounts: Dynamic recipient lists use untyped account arrays

Next Steps

  • Add Token-2022 support: The newer token standard supports transfer fees, confidential transfers, and more
  • Implement account closure: Close WalletReceiver accounts to reclaim rent when no longer needed
  • Add Merkle-based batch verification: Process hundreds of sweeps in a single transaction using compressed proofs
  • Deploy to devnet/mainnet: Run anchor deploy --provider.cluster devnet and update the program ID
  • Build a monitoring dashboard: Subscribe to program events (WalletDeployed, SolSwept, etc.) for real-time tracking
  • Add multisig ownership: Use a Squads multisig as the owner for production deployments

The complete source code is available in the wallet-factory-multichain repository on GitHub.


Built by Beltsys Labs. Licensed under MIT.

Disclaimer: The code in this tutorial is for educational purposes only. Before deploying to a mainnet, a professional security audit is strongly recommended. Beltsys Labs assumes no responsibility for any use made of this code.

Solana Anchor Rust PDA SPL Token

Need help with your project?

Our team of experts can implement these solutions for you.

Contact Us