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:
- Seeds – arbitrary byte arrays you choose (e.g.,
"wallet_receiver"+wallet_id) - Program ID – the program that “owns” the PDA
- Bump – a single byte (0-255) that ensures the derived address falls off the ed25519 curve
The derivation is:
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
| Aspect | EVM CREATE2 | Solana PDA |
|---|---|---|
| Determinism | keccak256(0xff, deployer, salt, initCodeHash) | SHA256(seeds, bump, programId) |
| Cost to create | Deploy contract (~32,000+ gas) | Create account (~0.002 SOL rent) |
| Code at address | Yes (proxy bytecode) | No (PDA is just an account) |
| Can receive before deploy | Yes (ETH only, not ERC-20) | Yes (SOL and SPL tokens) |
| Redeployable | Only with selfdestruct (deprecated) | No (account persists) |
| Authority | Owner/contract logic | Program that derived it |
| Off-chain computation | ethers.getCreate2Address() | PublicKey.findProgramAddressSync() |
| Max per program | Unlimited | Unlimited (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
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-langprovides the framework macros (#[program],#[account],#[derive(Accounts)])anchor-splprovides typed wrappers for the SPL Token program (used in token sweeps)solana-programis pinned to~1.18for compatibility with the Anchor versioncrate-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
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:
| Field | Type | Bytes |
|---|---|---|
| Discriminator | [u8; 8] | 8 |
owner | Pubkey | 32 |
relayer | Pubkey | 32 |
paused | bool | 1 |
bump | u8 | 1 |
| Total | 74 |
For WalletReceiver:
| Field | Type | Bytes |
|---|---|---|
| Discriminator | [u8; 8] | 8 |
wallet_id | [u8; 32] | 32 |
factory | Pubkey | 32 |
initialized | bool | 1 |
bump | u8 | 1 |
| Total | 74 |
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:
| Error | Code | When |
|---|---|---|
NotOwner | 6000 | Non-owner tries admin operation |
NotRelayer | 6001 | Non-relayer tries deploy/sweep |
Paused | 6002 | Any operation while factory is paused |
ZeroAddress | 6003 | Pubkey::default() passed as recipient or relayer |
TooManyRecipients | 6004 | More than 5 recipients in a sweep |
BpsDoNotSum | 6005 | Recipient BPS do not total 10,000 |
BpsOverflow | 6006 | Single recipient BPS exceeds 10,000 |
InvalidRecipients | 6007 | Empty recipients array |
NothingToSweep | 6008 | Wallet has zero sweepable balance |
Overflow | 6009 | Arithmetic 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:
- Anchor derives the PDA from
["factory_state"]+ the program ID - It calls
system_program::create_accountto allocate 74 bytes - The
ownerpays the rent-exempt lamports - The discriminator is written automatically (first 8 bytes)
- 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 theAccountsstruct – necessary for usingwallet_idin the seeds - The relayer pays rent for the new account (~0.00114 SOL)
- The
factory_stateis read-only here (nomut) – we only need to check itspausedandrelayerfields - Attempting to deploy the same
wallet_idtwice 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:
- The
initconstraint creates the account, absorbing the pre-funded lamports into the new account balance - 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:
- Your program constructs a transfer instruction specifying the PDA as the source
- It calls
invoke_signedwith the seeds that derive the PDA - The Solana runtime recomputes the PDA from those seeds + program ID
- 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:
| Operation | Required Signer | Checks Pause? |
|---|---|---|
initialize | Owner (implicit, first call only) | No |
deploy_wallet | Relayer | Yes |
sweep_sol | Relayer | Yes |
sweep_token | Relayer | Yes |
deploy_and_sweep_sol | Relayer | Yes |
deploy_and_sweep_token | Relayer | Yes |
emergency_sweep_sol | Owner | No |
emergency_sweep_token | Owner | No |
set_relayer | Owner | No |
pause | Owner | No |
unpause | Owner | No |
If the relayer key is compromised:
- Call
pause()– immediately blocks all relayer operations - Call
set_relayer(new_key)– rotate to a fresh key - Use
emergency_sweep_sol/emergency_sweep_tokento recover any at-risk funds - 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:
- In sweep_sol:
checked_sub(min_rent)returns an error if lamports are below rent - 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_receiveraccount 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
| Concern | EVM Wallet Factory | Solana Wallet Factory |
|---|---|---|
| Reentrancy | Major risk; use ReentrancyGuard | Not applicable (no callbacks in system_program::transfer) |
| Front-running | MEV bots can front-run deploys | Validators can reorder but PDAs are not race-sensitive |
| Proxy upgrade risk | Proxy pattern introduces upgrade vectors | No proxies; program upgrades are separate from data |
| Selfdestruct attacks | CREATE2 + selfdestruct redeploy | Not possible; PDA accounts persist |
| Token approval exploits | approve/transferFrom pattern | PDA is the authority; no approval needed |
| Gas griefing | Recipient contract can consume gas | Recipient 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
- PDAs replace CREATE2: Same determinism, no bytecode deployment, lower cost
- Rent exemption: You cannot sweep 100% of SOL – the rent minimum stays locked
- No reentrancy risk: System transfers and token CPI do not callback into your program
- Account model: State is in accounts, not contract storage slots
- Explicit account passing: Every account must be declared in the transaction
- 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 devnetand 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.


