# Wallet Factory Determinista en Solana con Anchor > Publicado originalmente en beltsys.com **Published by:** [Beltsys Labs](https://paragraph.com/@beltsyslabs/) **Published on:** 2026-03-25 **Categories:** **URL:** https://paragraph.com/@beltsyslabs/wallet-factory-determinista-en-solana-con-anchor ## Content IntroducciónLas plataformas de procesamiento de pagos, exchanges y servicios custodiales enfrentan un desafío común: necesitan generar direcciones de depósito únicas para cada usuario y luego consolidar los fondos recibidos en una tesorería central. En cadenas EVM (Ethereum, Polygon, etc.), el enfoque estándar utiliza los opcodes CREATE2 o CREATE3 para desplegar contratos proxy ligeros en direcciones deterministas. En Solana, logramos el mismo resultado usando Program Derived Addresses (PDAs) -- y el resultado es más elegante, más económico y posiblemente más seguro. Este tutorial recorre la construcción de un programa completo de Wallet Factory en Solana usando el framework Anchor. El programa:Despliega cuentas wallet receiver deterministas a partir de un identificador de 32 bytesHace sweep de SOL nativo con distribución en puntos base (BPS) a múltiples destinatariosHace sweep de tokens SPL con el mismo modelo de distribución por BPSSoporta deploy-y-sweep atómico en una sola transacciónIncluye recuperación de emergencia, pausa/reanudación y gestión de relayerAl finalizar, tendrás un programa Solana de nivel productivo con tests de integración completos en TypeScript. La arquitectura se traduce directamente de los patrones de wallet factory en EVM, por lo que si estás migrando desde Ethereum, esta guía conecta la brecha conceptual.¿Para quién es esto?Desarrolladores de Solana construyendo infraestructura de pagosDesarrolladores EVM migrando patrones de wallet factory a SolanaEquipos construyendo sistemas de depósito custodiales o semi-custodialesCualquier persona que quiera entender PDAs, CPIs y patrones de Anchor en profundidadPrerrequisitosRust y Cargo instaladosHerramientas CLI de Solana (v1.18+)Node.js 18+ y npmAnchor CLI (v0.30.1)Familiaridad básica con el modelo de cuentas de SolanaCómo Funcionan los PDAs en SolanaEl ProblemaNecesitas generar una dirección de depósito para el usuario #47291. En Ethereum, desplegarías un contrato proxy mínimo en una dirección determinista usando CREATE2(salt, bytecodeHash). La dirección es calculable off-chain antes del despliegue. En Solana, no existen "contratos" como tal -- hay programas (código sin estado) y cuentas (estado). No puedes "desplegar un contrato por usuario." En su lugar, derivas una Program Derived Address (PDA) que es única, determinista y controlada por tu programa.Seeds y BumpsUn PDA se deriva de:Seeds -- arrays de bytes arbitrarios que eliges (ej., "wallet_receiver" + wallet_id)Program ID -- el programa que "posee" el PDABump -- un solo byte (0-255) que asegura que la dirección derivada caiga fuera de la curva ed25519La derivación es:PDA = SHA256("wallet_receiver" || wallet_id || bump || program_id) Anchor encuentra el bump válido más alto automáticamente (el "bump canónico"). Como la dirección está fuera de la curva, no existe clave privada para ella -- solo el programa puede firmar por esta cuenta.Por Qué los PDAs Son DeterministasDados los mismos seeds y program ID, siempre obtienes la misma dirección. Esto significa:Tu backend puede calcular la dirección de depósito antes de que exista on-chainLos usuarios pueden enviar SOL o tokens a la dirección del PDA inmediatamenteEl programa puede luego "desplegar" la cuenta y hacer sweep de fondos en una sola transacciónEVM CREATE2 vs Solana PDAAspectoEVM CREATE2Solana PDADeterminismokeccak256(0xff, deployer, salt, initCodeHash)SHA256(seeds, bump, programId)Costo de creaciónDesplegar contrato (~32,000+ gas)Crear cuenta (~0.002 SOL de renta)Código en la direcciónSí (bytecode del proxy)No (el PDA es solo una cuenta)Puede recibir antes del deploySí (solo ETH, no ERC-20)Sí (SOL y tokens SPL)RedespliegableSolo con selfdestruct (deprecado)No (la cuenta persiste)AutoridadLógica del owner/contratoPrograma que lo derivóCálculo off-chainethers.getCreate2Address()PublicKey.findProgramAddressSync()Máximo por programaIlimitadoIlimitado (diferentes seeds)La ventaja clave en Solana: los PDAs pueden recibir tanto SOL como tokens SPL antes de que la cuenta sea inicializada por el programa. No existe una limitación equivalente a los tokens ERC-20 que requieren un contrato desplegado.Visión General de la Arquitectura +-----------------------+ | 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 Dos Tipos de CuentaFactoryState (PDA singleton) -- Uno por despliegue de programa. Almacena el owner, relayer, flag de pausa y bump del PDA. Seeds: ["factory_state"]. WalletReceiver (PDA por wallet) -- Uno por dirección de depósito. Almacena el wallet_id, referencia a la factory padre, flag de inicialización y bump. Seeds: ["wallet_receiver", wallet_id].Control de Acceso Basado en RolesOwner -- El deployer. Puede pausar/reanudar la factory, cambiar el relayer y ejecutar sweeps de emergencia (ignora la pausa).Relayer -- Una clave de backend automatizada. Puede desplegar wallets y ejecutar sweeps regulares. No puede modificar el estado de la factory ni hacer sweeps de emergencia.Esta separación significa que si tu clave hot del relayer se ve comprometida, el radio de explosión es limitado.Configuración del ProyectoInicializar el Proyecto Anchoranchor init wallet-factory cd wallet-factory Cargo.tomlEl Cargo.toml del programa define las dependencias de Anchor y la librería SPL Token:[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 Puntos clave:anchor-lang proporciona los macros del framework (#[program], #[account], #[derive(Accounts)])anchor-spl proporciona wrappers tipados para el programa SPL Token (usado en sweeps de tokens)solana-program está fijado a ~1.18 para compatibilidad con la versión de Anchorcrate-type = ["cdylib", "lib"] compila tanto una librería compartida (para on-chain) como una librería Rust (para 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" Reemplaza el program ID con el generado por anchor keys list después de tu primer build.package.json (Dependencias de Test){ "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" } } Instalar dependencias:Estructura de Archivosprograms/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) Cuentas de EstadoEl módulo de estado define las dos cuentas on-chain. Cada cuenta en Anchor tiene un discriminador de 8 bytes (SHA256 del nombre de la cuenta) antepuesto automáticamente.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 } Desglose del Cálculo de EspacioPara FactoryState:CampoTipoBytesDiscriminador[u8; 8]8ownerPubkey32relayerPubkey32pausedbool1bumpu81Total74Para WalletReceiver:CampoTipoBytesDiscriminador[u8; 8]8wallet_id[u8; 32]32factoryPubkey32initializedbool1bumpu81Total74Ambas cuentas tienen exactamente 74 bytes. A las tasas actuales de renta (~6.96 lamports por byte-época), el mínimo exento de renta para una cuenta de 74 bytes es aproximadamente 0.00114 SOL.¿Por Qué Almacenar el Bump?El bump del PDA se almacena on-chain para que las instrucciones posteriores no necesiten recalcularlo. Cuando accedes a wallet_receiver.bump, te ahorras el costo de find_program_address en el runtime. La restricción bump = factory_state.bump de Anchor usa el valor almacenado para verificación.Diseño de SeedsEl seed b"factory_state" es un prefijo estático -- solo puede existir un FactoryState por programa. El seed b"wallet_receiver" combinado con wallet_id (un array de 32 bytes) crea un PDA único por wallet. El wallet_id se mapea a cualquier identificador que use tu backend -- un UUID, hash de ID de usuario o número de orden. Usar 32 bytes (igual que un bytes32 en Solidity) hace que el mapeo de IDs entre cadenas sea directo.Definición de ErroresLos errores personalizados proporcionan mensajes de fallo claros y códigos de error distintos para el manejo del lado del cliente: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, } Cada variante se convierte en un código de error de Anchor (6000 + índice). Tu cliente TypeScript puede hacer match sobre estos códigos:ErrorCódigoCuándoNotOwner6000Un no-owner intenta una operación adminNotRelayer6001Un no-relayer intenta deploy/sweepPaused6002Cualquier operación mientras la factory está pausadaZeroAddress6003Se pasa Pubkey::default() como destinatario o relayerTooManyRecipients6004Más de 5 destinatarios en un sweepBpsDoNotSum6005Los BPS de los destinatarios no suman 10,000BpsOverflow6006Un BPS individual de destinatario excede 10,000InvalidRecipients6007Array de destinatarios vacíoNothingToSweep6008La wallet tiene balance sweepable de ceroOverflow6009Overflow aritmético en la distribuciónPunto de Entrada del ProgramaEl archivo lib.rs declara el program ID, todas las instrucciones y la struct Recipient usada en las operaciones de sweep: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 } Decisiones de Diseñolib.rs delgado: Cada instrucción delega a su propio archivo en el módulo instructions/. Esto mantiene el punto de entrada legible y cada instrucción autocontenida con su función handler y struct Accounts. Recipient en la raíz del crate: La struct Recipient es usada por seis instrucciones diferentes. Colocarla en lib.rs evita importaciones circulares entre módulos de instrucciones. wallet_id como [u8; 32]: Un array de bytes de tamaño fijo coincide con el tipo bytes32 de Solidity, haciendo trivial el mapeo de IDs entre cadenas. Tu backend genera un identificador único de 32 bytes por dirección de depósito. BPS (Basis Points): 10,000 BPS = 100%. Esto permite divisiones como 70/30 (7000/3000) o 50/25/25 (5000/2500/2500) sin matemática de punto flotante.Módulo de InstruccionesEl archivo del módulo re-exporta todos los tipos de instrucciones: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::*; Instrucciones PrincipalesInitializeCrea el PDA singleton FactoryState, estableciendo al llamante como owner y asignando el 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, } Lo que sucede internamente:Anchor deriva el PDA desde ["factory_state"] + el program IDLlama a system_program::create_account para asignar 74 bytesEl owner paga los lamports exentos de rentaEl discriminador se escribe automáticamente (primeros 8 bytes)Nuestro handler rellena los campos restantesLa restricción init asegura que esta instrucción solo puede tener éxito una vez -- llamarla de nuevo falla porque la cuenta ya existe.Deploy WalletCrea un nuevo PDA WalletReceiver para un wallet_id dado. Este es el equivalente en Solana de desplegar un proxy CREATE2: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, } Detalles clave:El atributo #[instruction(wallet_id: [u8; 32])] permite a Anchor acceder a los argumentos de la instrucción en la struct Accounts -- necesario para usar wallet_id en los seedsEl relayer paga la renta por la nueva cuenta (~0.00114 SOL)El factory_state es solo lectura aquí (sin mut) -- solo necesitamos verificar sus campos paused y relayerIntentar desplegar el mismo wallet_id dos veces falla porque el PDA ya existeSweep SOLEsta es la instrucción principal más compleja. Hace sweep de SOL desde un PDA WalletReceiver a múltiples destinatarios basándose en puntos base, mientras preserva el mínimo exento de renta: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, } Conceptos críticos en esta instrucción: Exención de renta: En Solana, las cuentas deben mantener un balance mínimo de lamports para evitar ser recolectadas por el garbage collector. El balance sweepable es total_lamports - mínimo_exento_de_renta. No puedes hacer sweep del balance completo sin cerrar la cuenta. Esto es fundamentalmente diferente de EVM donde el balance de ETH está completamente disponible. Firma de PDA con invoke_signed: Los PDAs no tienen clave privada. Para transferir SOL desde un PDA, construyes una instrucción de sistema y llamas a invoke_signed con los seeds + bump del PDA. El runtime verifica que los seeds deriven la dirección correcta y permite la transferencia. Distribución libre de dust: El último destinatario recibe balance - distributed en lugar de un cálculo por BPS. Esto asegura que hasta el último lamport sea distribuido y evita que se acumule dust de redondeo en el PDA. Matemática intermedia en u128: El cálculo de BPS hace cast a u128 antes de multiplicar: (balance as u128 * bps as u128 / 10_000) as u64. Esto previene overflow cuando balance * bps excede u64::MAX (lo cual ocurre por encima de ~1.8 mil millones de SOL -- improbable pero defensivo).Sweep TokenLos sweeps de tokens SPL siguen un patrón similar pero usan Cross-Program Invocations (CPI) al programa Token en lugar de transferencias del sistema. Las cuentas de token destino se pasan a través de 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, } Diferencias clave respecto al sweep de SOL: Sin preocupación por exención de renta para tokens: El balance de la cuenta SPL Token es la cantidad total de tokens (la exención de renta aplica al SOL en la cuenta de token, no al balance de tokens en sí). Así que hacemos sweep del source_token_account.amount completo. CPI al programa Token: En lugar de invoke_signed con una instrucción de transferencia del sistema, usamos token::transfer de Anchor con CpiContext::new_with_signer. Esto crea una Cross-Program Invocation donde el PDA WalletReceiver firma como la autoridad del token. remaining_accounts para destinatarios dinámicos: La struct Accounts de Anchor requiere cuentas definidas estáticamente. Como el número de destinatarios varía (1-5), las cuentas de token destino se pasan a través de ctx.remaining_accounts. El índice i en el vector de destinatarios se mapea a remaining_accounts[i]. Restricción de propiedad del origen: La constraint = source_token_account.owner == wallet_receiver.key() asegura que la cuenta de token realmente pertenezca al PDA. Sin esto, alguien podría pasar una cuenta de token arbitraria.Deploy y Sweep SOL (Atómico)Esta instrucción combina el despliegue de wallet y el sweep de SOL en una sola transacción atómica. Crítica para el caso donde quieres desplegar e inmediatamente hacer sweep de lamports pre-fondeados: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>, } Por qué lo atómico importa: En Solana, puedes enviar SOL a una dirección PDA antes de que la cuenta sea inicializada por el programa. Los lamports quedan en esa dirección. Cuando deploy_and_sweep_sol se ejecuta:La restricción init crea la cuenta, absorbiendo los lamports pre-fondeados en el balance de la nueva cuentaEl handler hace sweep inmediato de cualquier balance por encima de la exención de rentaEsto significa que un usuario puede depositar SOL, y tu backend puede desplegar + hacer sweep en una transacción -- una firma, una confirmación de bloque. En el mundo EVM, esto requeriría dos transacciones (desplegar proxy, luego llamar sweep). saturating_sub vs checked_sub: La versión atómica usa saturating_sub en lugar de checked_sub. Si el PDA fue recién creado con exactamente la cantidad de renta (sin pre-fondeo), balance se vuelve 0 y el bloque de sweep se salta elegantemente. El sweep_sol independiente usa checked_sub y retorna un error porque llamar sweep sin nada que hacer sweep es inesperado.Deploy y Sweep Token (Atómico)La versión SPL token del deploy-y-sweep atómico: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) } Flujo de pre-fondeo de tokens: Para tokens SPL, el flujo requiere que alguien cree una Associated Token Account (ATA) para el PDA antes de enviar tokens. La dirección de la ATA también es determinista (derivada de la dirección del PDA + dirección del mint), por lo que tu backend puede calcularla y mostrarla al usuario. La instrucción deploy_and_sweep_token luego despliega el WalletReceiver y hace sweep del balance de tokens de una sola vez.Instrucciones de Emergencia y AdministraciónEmergency Sweep SOLEl owner puede recuperar todo el SOL sweepable de cualquier wallet a un solo destino. Esto ignora la verificación de pausa y no requiere el 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, } Sin verificación de pausa: Los sweeps de emergencia intencionalmente omiten require!(!state.paused, ...). El escenario: detectas que la clave del relayer está comprometida, pausas la factory, luego usas la clave del owner para evacuar fondos. Si el sweep de emergencia respetara la pausa, tendrías que reanudar (re-exponiendo el vector de ataque del relayer) para recuperar fondos. SystemAccount para destino: A diferencia de los sweeps regulares donde los destinatarios se pasan como Pubkey en la struct Recipient, el destino de emergencia es un SystemAccount validado. Esto es por conveniencia -- el destino solo necesita ser una cuenta de sistema válida, no una cuenta propiedad de un programa.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 RelayerActualiza la clave pública del relayer. Solo el owner puede llamar esto: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, } Restricción has_one = owner: Esta restricción de Anchor verifica automáticamente que factory_state.owner == owner.key(). Es equivalente al manual require!(ctx.accounts.owner.key() == state.owner, ...) pero más idiomático. Usa has_one cuando el nombre del campo en la struct de cuenta coincide con el nombre de la cuenta en la struct Accounts.Pause y UnpauseOperaciones simples de toggle protegidas por 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>, } Estas son intencionalmente simples. En producción, podrías agregar:Un delay de timelock para reanudarEmisión de eventos para pausa/reanudaciónUn contador que registre cuántas veces se ha pausado la factory (para monitoreo)Conceptos Clave de Solana ExplicadosExención de RentaCada cuenta de Solana debe mantener un balance mínimo de SOL basado en su tamaño de datos. Esto previene spam en la red y asegura que los validadores sean compensados por almacenar estado. Para nuestra cuenta WalletReceiver de 74 bytes, el mínimo exento de renta es aproximadamente 0.00114 SOL (1,141,440 lamports a las tasas actuales). Esto significa:Si un usuario deposita 1 SOL en un PDA wallet receiver, el balance sweepable es ~0.99886 SOLEl mínimo exento de renta permanece en el PDA para siempre (a menos que se cierre la cuenta)Tu backend debería tener esto en cuenta al mostrar balances disponiblesLa fórmula es: sweepable = total_lamports - rent.minimum_balance(account_size) Esta es una diferencia fundamental con EVM. En Ethereum, puedes hacer sweep del balance completo de ETH de un contrato. En Solana, debes dejar la renta.Firma de PDA con SeedsUn PDA no tiene clave privada. Entonces, ¿cómo "firma" transacciones? No lo hace, en el sentido tradicional. En su lugar:Tu programa construye una instrucción de transferencia especificando el PDA como origenLlama a invoke_signed con los seeds que derivan el PDAEl runtime de Solana recalcula el PDA desde esos seeds + program IDSi el resultado coincide con la cuenta en la instrucción, la transferencia es autorizadalet 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, )?; Esto es criptográficamente seguro: solo el programa que derivó el PDA puede producir seeds de firma válidos para él. Ningún otro programa puede firmar por tu PDA.Cross-Program Invocation (CPI) para Transferencias de TokensLas transferencias de SOL nativo usan el System Program. Las transferencias de tokens SPL usan el Token Program. Para transferir tokens desde un PDA, realizas una 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)?; El CpiContext::new_with_signer de Anchor envuelve la llamada de bajo nivel invoke_signed en una interfaz tipada. El Token Program verifica que la authority (nuestro PDA) firmó la transacción usando los seeds proporcionados.remaining_accounts para Listas Dinámicas de DestinatariosLa struct #[derive(Accounts)] de Anchor es estática -- defines todas las cuentas en tiempo de compilación. Pero los sweeps de tokens necesitan un número variable de cuentas de token destino (1-5). La solución es remaining_accounts:// In the Accounts struct: // remaining_accounts: destination TokenAccounts (one per recipient) // In the handler: let dest_account = &ctx.remaining_accounts[i]; El cliente pasa estas cuentas extra al construir la transacción: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(); Este patrón es común en programas de Solana que necesitan manejar listas de cuentas de longitud variable. La contrapartida es que remaining_accounts son referencias AccountInfo sin tipo -- pierdes la deserialización automática y la verificación de restricciones de Anchor.Tests de IntegraciónLa suite de tests completa valida todos los flujos principales usando el framework de testing TypeScript de Anchor: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); }); }); Explicación de los TestsSetup: El test crea un provider (conexión + wallet), la interfaz del programa y keypairs para el relayer y dos destinatarios. El hook before hace airdrop de 2 SOL al relayer para que pueda pagar fees de transacción y renta de cuentas. Derivación de PDA: El helper walletReceiverPDA usa PublicKey.findProgramAddressSync para calcular la misma dirección que el programa on-chain derivará. Esta es la clave del direccionamiento determinista -- tu código off-chain y tu programa on-chain coinciden en la dirección antes de que exista. wallet_id como Buffer: El wallet_id de 32 bytes se crea como un Buffer relleno de ceros con un prefijo legible escrito. En producción, usarías un UUID o hash. El operador spread [...walletId] lo convierte a un number[] que el serializador de Anchor espera. Fondeo antes del deploy: El test de sweep atómico demuestra el pre-fondeo: se envía SOL a la dirección del PDA antes de que la cuenta WalletReceiver sea creada. Cuando deploy_and_sweep_sol se ejecuta, la restricción init crea la cuenta (heredando los lamports pre-fondeados), y el handler hace sweep de inmediato. Aserciones de balance: assert.approximately permite una tolerancia de 1000 lamports (~0.000001 SOL) para considerar el posible redondeo en la distribución por BPS.Ejecutar los Tests# Start local validator solana-test-validator # In another terminal anchor build anchor deploy anchor test O con un solo comando que maneja todo:anchor test Generación de Direcciones de Wallet Off-ChainTu backend necesita calcular direcciones de depósito sin interactuar con la blockchain. Así es como se deriva cualquier dirección de wallet receiver: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 Para Depósitos de Tokens SPLPara recibir tokens SPL, el usuario necesita la dirección de la Associated Token Account (ATA) del 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()}`); El parámetro allowOwnerOffCurve = true es esencial porque el owner del PDA está fuera de la curva ed25519. Sin él, la función lanza un error.Generación de Direcciones en LotePara generar miles de direcciones de depósito: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 es un cálculo de hash puro -- sin llamadas RPC. Generar 10,000 direcciones toma menos de un segundo en hardware moderno.Consideraciones de SeguridadControl de AccesoEl programa aplica control de acceso de dos niveles:OperaciónFirmante Requerido¿Verifica Pausa?initializeOwner (implícito, solo primera llamada)Nodeploy_walletRelayerSísweep_solRelayerSísweep_tokenRelayerSídeploy_and_sweep_solRelayerSídeploy_and_sweep_tokenRelayerSíemergency_sweep_solOwnerNoemergency_sweep_tokenOwnerNoset_relayerOwnerNopauseOwnerNounpauseOwnerNoSi la clave del relayer se ve comprometida:Llamar pause() -- bloquea inmediatamente todas las operaciones del relayerLlamar set_relayer(new_key) -- rotar a una clave nuevaUsar emergency_sweep_sol / emergency_sweep_token para recuperar fondos en riesgoLlamar unpause() -- reanudar operaciones con el nuevo relayerGarantías de Exención de RentaEl programa nunca permite hacer sweep por debajo del mínimo exento de renta. Esto se aplica en dos niveles:En sweep_sol: checked_sub(min_rent) retorna un error si los lamports están por debajo de la rentaEn deploy_and_sweep_sol: saturating_sub(min_rent) retorna 0 (salta el sweep) si está en el mínimoSin esto, el runtime de Solana haría garbage collection de la cuenta, perdiendo permanentemente el PDA y cualquier depósito futuro a esa dirección.Validación de PDACada instrucción valida los PDAs a través de las restricciones de seeds de Anchor:#[account( seeds = [WalletReceiver::SEED, &wallet_id], bump = wallet_receiver.bump, )] pub wallet_receiver: Account<'info, WalletReceiver>, Esto asegura:La cuenta wallet_receiver fue creada por este programa (verificación de discriminador)La dirección de la cuenta coincide con el PDA esperado para el wallet_id dado (verificación de seeds)El bump almacenado coincide con el bump canónico (verificación de bump)Un atacante no puede sustituir una cuenta diferente o un PDA de otro programa.Validación de BPSCada instrucción de sweep valida que:Se proporcione al menos 1 destinatarioNo más de 5 destinatarios (limita el uso de cómputo y el tamaño de transacción)Ningún destinatario tenga Pubkey::default() (la dirección cero)Ningún BPS individual exceda 10,000Los BPS totales sumen exactamente 10,000El límite de 5 destinatarios es una restricción práctica. Cada destinatario requiere una llamada adicional a invoke_signed (para SOL) o CPI (para tokens). Las transacciones de Solana tienen un presupuesto de cómputo de 200,000 unidades de cómputo por defecto -- 5 destinatarios se mantiene holgadamente dentro de este límite.Comparación con el Modelo de Seguridad EVMPreocupaciónWallet Factory EVMWallet Factory SolanaReentranciaRiesgo mayor; usar ReentrancyGuardNo aplica (sin callbacks en system_program::transfer)Front-runningBots de MEV pueden hacer front-run a deploysLos validadores pueden reordenar pero los PDAs no son sensibles a race conditionsRiesgo de upgrade de proxyEl patrón proxy introduce vectores de upgradeSin proxies; los upgrades del programa son separados de los datosAtaques con selfdestructCREATE2 + selfdestruct para re-desplegarNo es posible; las cuentas PDA persistenExploits de aprobación de tokensPatrón approve/transferFromEl PDA es la autoridad; no se necesita aprobaciónGas griefingEl contrato destinatario puede consumir gasLas cuentas destinatarias son pasivas; sin callbackEl modelo de cuentas de Solana elimina varios vectores de ataque que afectan a las wallet factories de EVM:Sin reentrancia: las transferencias del sistema y transferencias de tokens SPL no ejecutan código arbitrario en el destinatarioSin cadenas de aprobación: el PDA posee directamente la cuenta de token y es la autoridad de transferenciaSin re-despliegue por self-destruct: una vez que una cuenta PDA existe, no puede ser destruida y recreada con estado diferenteConclusiónQué ConstruimosUna wallet factory determinista completa en Solana que:Crea direcciones de depósito únicas a partir de un identificador de 32 bytes usando PDAsDistribuye SOL recibido a múltiples destinatarios basándose en puntos baseDistribuye tokens SPL recibidos usando CPI al Token ProgramSoporta deploy-y-sweep atómico tanto para SOL como para tokens SPLIncluye recuperación de emergencia que ignora el mecanismo de pausaAplica control de acceso de dos niveles (owner / relayer)Proporciona tests de integración en TypeScript y derivación de direcciones off-chainDiferencias Clave con EVMLos PDAs reemplazan a CREATE2: Mismo determinismo, sin despliegue de bytecode, menor costoExención de renta: No puedes hacer sweep del 100% del SOL -- el mínimo de renta queda bloqueadoSin riesgo de reentrancia: Las transferencias del sistema y CPI de tokens no hacen callback a tu programaModelo de cuentas: El estado está en cuentas, no en slots de storage de contratosPaso explícito de cuentas: Cada cuenta debe ser declarada en la transacciónremaining_accounts: Las listas dinámicas de destinatarios usan arrays de cuentas sin tipoPróximos PasosAgregar soporte para Token-2022: El estándar de token más nuevo soporta fees de transferencia, transferencias confidenciales y másImplementar cierre de cuentas: Cerrar cuentas WalletReceiver para recuperar renta cuando ya no se necesitenAgregar verificación por lotes basada en Merkle: Procesar cientos de sweeps en una sola transacción usando pruebas comprimidasDesplegar en devnet/mainnet: Ejecutar anchor deploy --provider.cluster devnet y actualizar el program IDConstruir un dashboard de monitoreo: Suscribirse a eventos del programa (WalletDeployed, SolSwept, etc.) para seguimiento en tiempo realAgregar propiedad multisig: Usar un multisig de Squads como owner para despliegues en producciónEl código fuente completo está disponible en el repositorio wallet-factory-multichain en GitHub. Desarrollado por Beltsys Labs. Licencia MIT.Publicado originalmente en 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