# Building a Deterministic Wallet Factory on Solana with Anchor > Originally published on beltsys.com **Published by:** [Beltsys Labs](https://paragraph.com/@beltsyslabs/) **Published on:** 2026-03-25 **Categories:** **URL:** https://paragraph.com/@beltsyslabs/building-a-deterministic-wallet-factory-on-solana-with-anchor ## Content IntroductionPayment 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 identifierSweeps native SOL with basis-point (BPS) distribution to multiple recipientsSweeps SPL tokens with the same BPS distribution modelSupports atomic deploy-and-sweep in a single transactionIncludes emergency recovery, pause/unpause, and relayer managementBy 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 infrastructureEVM developers migrating wallet factory patterns to SolanaTeams building custodial or semi-custodial deposit systemsAnyone who wants to understand PDAs, CPIs, and Anchor patterns in depthPrerequisitesRust and Cargo installedSolana CLI tools (v1.18+)Node.js 18+ and npmAnchor CLI (v0.30.1)Basic familiarity with Solana's account modelHow Solana PDAs WorkThe ProblemYou 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 BumpsA PDA is derived from:Seeds -- arbitrary byte arrays you choose (e.g., "wallet_receiver" + wallet_id)Program ID -- the program that "owns" the PDABump -- a single byte (0-255) that ensures the derived address falls off the ed25519 curveThe derivation is:PDA = SHA256("wallet_receiver" || wallet_id || bump || program_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 DeterministicGiven 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-chainUsers can send SOL or tokens to the PDA address immediatelyThe program can later "deploy" the account and sweep funds in a single transactionEVM CREATE2 vs Solana PDAAspectEVM CREATE2Solana PDADeterminismkeccak256(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 itOff-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 +-----------------------+ | Backend / Relayer | | (TypeScript client) | +-----------+-----------+ | initialize / deploy / sweep / admin | v +-----------------------+ | WalletFactory Program | | (Rust / Anchor) | +-----------+-----------+ | +-----------+---------+---------+-----------+ | | | | v v v v +-----------+ +-----------+ +-----------+ +-----------+ | Factory | | Wallet | | Wallet | | Wallet | | State | | Receiver | | Receiver | | Receiver | | (PDA) | | PDA #1 | | PDA #2 | | PDA #N | +-----------+ +-----------+ +-----------+ +-----------+ seeds: seeds: seeds: seeds: "factory_ "wallet_ "wallet_ "wallet_ state" receiver" receiver" receiver" + wallet_id_1 + wallet_id_2 + wallet_id_N Two Account TypesFactoryState (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 AccessOwner -- 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 SetupInitialize the Anchor Projectanchor init wallet-factory cd wallet-factory Cargo.tomlThe 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 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:File Structureprograms/wallet_factory/src/ lib.rs # Program entry point, instruction declarations errors.rs # Custom error codes state/ mod.rs # FactoryState + WalletReceiver account structs instructions/ mod.rs # Module re-exports initialize.rs # Create FactoryState deploy_wallet.rs # Create WalletReceiver PDA sweep_sol.rs # Sweep SOL with BPS distribution sweep_token.rs # Sweep SPL tokens with BPS distribution deploy_and_sweep_sol.rs # Atomic deploy + sweep SOL deploy_and_sweep_token.rs # Atomic deploy + sweep SPL tokens emergency_sweep_sol.rs # Owner-only SOL recovery emergency_sweep_token.rs # Owner-only token recovery set_relayer.rs # Update relayer pubkey pause.rs # Pause the factory unpause.rs # Unpause the factory tests/ wallet-factory.ts # Integration tests (TypeScript) State AccountsThe 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 BreakdownFor FactoryState:FieldTypeBytesDiscriminator[u8; 8]8ownerPubkey32relayerPubkey32pausedbool1bumpu81Total74For WalletReceiver:FieldTypeBytesDiscriminator[u8; 8]8wallet_id[u8; 32]32factoryPubkey32initializedbool1bumpu81Total74Both 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 DesignThe 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 DefinitionsCustom 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:ErrorCodeWhenNotOwner6000Non-owner tries admin operationNotRelayer6001Non-relayer tries deploy/sweepPaused6002Any operation while factory is pausedZeroAddress6003Pubkey::default() passed as recipient or relayerTooManyRecipients6004More than 5 recipients in a sweepBpsDoNotSum6005Recipient BPS do not total 10,000BpsOverflow6006Single recipient BPS exceeds 10,000InvalidRecipients6007Empty recipients arrayNothingToSweep6008Wallet has zero sweepable balanceOverflow6009Arithmetic overflow in distributionProgram Entry PointThe 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 DecisionsThin 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 ModuleThe 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 InstructionsInitializeCreates 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 IDIt calls system_program::create_account to allocate 74 bytesThe owner pays the rent-exempt lamportsThe discriminator is written automatically (first 8 bytes)Our handler fills in the remaining fieldsThe init constraint ensures this instruction can only succeed once -- calling it again fails because the account already exists.Deploy WalletCreates 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 seedsThe 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 fieldsAttempting to deploy the same wallet_id twice fails because the PDA already existsSweep SOLThis 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 TokenSPL 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 init constraint creates the account, absorbing the pre-funded lamports into the new account balanceThe handler immediately sweeps any balance above rent exemptionThis 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 InstructionsEmergency Sweep SOLThe 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 Tokenuse 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 RelayerUpdates 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 UnpauseSimple 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 unpausingEvent emissions for pause/unpauseA counter tracking how many times the factory has been paused (for monitoring)Key Solana Concepts ExplainedRent ExemptionEvery 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 SOLThe rent-exempt minimum stays in the PDA forever (unless the account is closed)Your backend should account for this when displaying available balancesThe 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 SeedsA 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 sourceIt calls invoke_signed with the seeds that derive the PDAThe Solana runtime recomputes the PDA from those seeds + program IDIf the result matches the account in the instruction, the transfer is authorizedlet 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 TransfersNative 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 ListsAnchor'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 TestsThe 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 WalkthroughSetup: 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-ChainYour 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 DepositsTo 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 GenerationFor 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 ConsiderationsAccess ControlThe program enforces two-tier access control:OperationRequired SignerChecks Pause?initializeOwner (implicit, first call only)Nodeploy_walletRelayerYessweep_solRelayerYessweep_tokenRelayerYesdeploy_and_sweep_solRelayerYesdeploy_and_sweep_tokenRelayerYesemergency_sweep_solOwnerNoemergency_sweep_tokenOwnerNoset_relayerOwnerNopauseOwnerNounpauseOwnerNoIf the relayer key is compromised:Call pause() -- immediately blocks all relayer operationsCall set_relayer(new_key) -- rotate to a fresh keyUse emergency_sweep_sol / emergency_sweep_token to recover any at-risk fundsCall unpause() -- resume operations with the new relayerRent Exemption GuaranteesThe 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 rentIn deploy_and_sweep_sol: saturating_sub(min_rent) returns 0 (skip sweep) if at minimumWithout this, the Solana runtime would garbage-collect the account, permanently losing the PDA and any future deposits to that address.PDA ValidationEvery 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 ValidationEvery sweep instruction validates that:At least 1 recipient is providedNo more than 5 recipients (limits compute usage and transaction size)No recipient has Pubkey::default() (the zero address)No individual BPS exceeds 10,000Total BPS sums to exactly 10,000The 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 ModelConcernEVM Wallet FactorySolana Wallet FactoryReentrancyMajor risk; use ReentrancyGuardNot applicable (no callbacks in system_program::transfer)Front-runningMEV bots can front-run deploysValidators can reorder but PDAs are not race-sensitiveProxy upgrade riskProxy pattern introduces upgrade vectorsNo proxies; program upgrades are separate from dataSelfdestruct attacksCREATE2 + selfdestruct redeployNot possible; PDA accounts persistToken approval exploitsapprove/transferFrom patternPDA is the authority; no approval neededGas griefingRecipient contract can consume gasRecipient accounts are passive; no callbackSolana'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 recipientNo approval chains: the PDA directly owns the token account and is the transfer authorityNo self-destruct redeploy: once a PDA account exists, it cannot be destroyed and recreated with different stateConclusionWhat We BuiltA complete deterministic wallet factory on Solana that:Creates unique deposit addresses from a 32-byte identifier using PDAsDistributes received SOL to multiple recipients based on basis pointsDistributes received SPL tokens using CPI to the Token ProgramSupports atomic deploy-and-sweep for both SOL and SPL tokensIncludes emergency recovery bypassing the pause mechanismEnforces two-tier access control (owner / relayer)Provides TypeScript integration tests and off-chain address derivationKey Differences from EVMPDAs replace CREATE2: Same determinism, no bytecode deployment, lower costRent exemption: You cannot sweep 100% of SOL -- the rent minimum stays lockedNo reentrancy risk: System transfers and token CPI do not callback into your programAccount model: State is in accounts, not contract storage slotsExplicit account passing: Every account must be declared in the transactionremaining_accounts: Dynamic recipient lists use untyped account arraysNext StepsAdd Token-2022 support: The newer token standard supports transfer fees, confidential transfers, and moreImplement account closure: Close WalletReceiver accounts to reclaim rent when no longer neededAdd Merkle-based batch verification: Process hundreds of sweeps in a single transaction using compressed proofsDeploy to devnet/mainnet: Run anchor deploy --provider.cluster devnet and update the program IDBuild a monitoring dashboard: Subscribe to program events (WalletDeployed, SolSwept, etc.) for real-time trackingAdd multisig ownership: Use a Squads multisig as the owner for production deploymentsThe complete source code is available in the wallet-factory-multichain repository on GitHub. Built by Beltsys Labs. Licensed under MIT.Originally published on beltsys.com ## Publication Information - [Beltsys Labs](https://paragraph.com/@beltsyslabs/): Publication homepage - [All Posts](https://paragraph.com/@beltsyslabs/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@beltsyslabs): Subscribe to updates