Introducción
Las 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 bytes
- Hace sweep de SOL nativo con distribución en puntos base (BPS) a múltiples destinatarios
- Hace sweep de tokens SPL con el mismo modelo de distribución por BPS
- Soporta deploy-y-sweep atómico en una sola transacción
- Incluye recuperación de emergencia, pausa/reanudación y gestión de relayer
Al 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 pagos
- Desarrolladores EVM migrando patrones de wallet factory a Solana
- Equipos construyendo sistemas de depósito custodiales o semi-custodiales
- Cualquier persona que quiera entender PDAs, CPIs y patrones de Anchor en profundidad
Prerrequisitos
- Rust y Cargo instalados
- Herramientas CLI de Solana (v1.18+)
- Node.js 18+ y npm
- Anchor CLI (v0.30.1)
- Familiaridad básica con el modelo de cuentas de Solana
Cómo Funcionan los PDAs en Solana
El Problema
Necesitas 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 Bumps
Un PDA se deriva de:
- Seeds – arrays de bytes arbitrarios que eliges (ej.,
"wallet_receiver"+wallet_id) - Program ID – el programa que “posee” el PDA
- Bump – un solo byte (0-255) que asegura que la dirección derivada caiga fuera de la curva ed25519
La derivación es:
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 Deterministas
Dados 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-chain
- Los usuarios pueden enviar SOL o tokens a la dirección del PDA inmediatamente
- El programa puede luego “desplegar” la cuenta y hacer sweep de fondos en una sola transacción
EVM CREATE2 vs Solana PDA
| Aspecto | EVM CREATE2 | Solana PDA |
|---|---|---|
| Determinismo | keccak256(0xff, deployer, salt, initCodeHash) | SHA256(seeds, bump, programId) |
| Costo de creación | Desplegar contrato (~32,000+ gas) | Crear cuenta (~0.002 SOL de renta) |
| Código en la dirección | Sí (bytecode del proxy) | No (el PDA es solo una cuenta) |
| Puede recibir antes del deploy | Sí (solo ETH, no ERC-20) | Sí (SOL y tokens SPL) |
| Redespliegable | Solo con selfdestruct (deprecado) | No (la cuenta persiste) |
| Autoridad | Lógica del owner/contrato | Programa que lo derivó |
| Cálculo off-chain | ethers.getCreate2Address() | PublicKey.findProgramAddressSync() |
| Máximo por programa | Ilimitado | Ilimitado (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
Dos Tipos de Cuenta
FactoryState (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 Roles
- Owner – 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 Proyecto
Inicializar el Proyecto Anchor
anchor init wallet-factory
cd wallet-factory
Cargo.toml
El 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-langproporciona los macros del framework (#[program],#[account],#[derive(Accounts)])anchor-splproporciona wrappers tipados para el programa SPL Token (usado en sweeps de tokens)solana-programestá fijado a~1.18para 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:
npm install
Estructura de Archivos
Cuentas de Estado
El 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 Espacio
Para FactoryState:
| Campo | Tipo | Bytes |
|---|---|---|
| Discriminador | [u8; 8] | 8 |
owner | Pubkey | 32 |
relayer | Pubkey | 32 |
paused | bool | 1 |
bump | u8 | 1 |
| Total | 74 |
Para WalletReceiver:
| Campo | Tipo | Bytes |
|---|---|---|
| Discriminador | [u8; 8] | 8 |
wallet_id | [u8; 32] | 32 |
factory | Pubkey | 32 |
initialized | bool | 1 |
bump | u8 | 1 |
| Total | 74 |
Ambas 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 Seeds
El 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 Errores
Los 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:
| Error | Código | Cuándo |
|---|---|---|
NotOwner | 6000 | Un no-owner intenta una operación admin |
NotRelayer | 6001 | Un no-relayer intenta deploy/sweep |
Paused | 6002 | Cualquier operación mientras la factory está pausada |
ZeroAddress | 6003 | Se pasa Pubkey::default() como destinatario o relayer |
TooManyRecipients | 6004 | Más de 5 destinatarios en un sweep |
BpsDoNotSum | 6005 | Los BPS de los destinatarios no suman 10,000 |
BpsOverflow | 6006 | Un BPS individual de destinatario excede 10,000 |
InvalidRecipients | 6007 | Array de destinatarios vacío |
NothingToSweep | 6008 | La wallet tiene balance sweepable de cero |
Overflow | 6009 | Overflow aritmético en la distribución |
Punto de Entrada del Programa
El 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ño
lib.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 Instrucciones
El 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 Principales
Initialize
Crea 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 ID - Llama a
system_program::create_accountpara asignar 74 bytes - El
ownerpaga los lamports exentos de renta - El discriminador se escribe automáticamente (primeros 8 bytes)
- Nuestro handler rellena los campos restantes
La restricción init asegura que esta instrucción solo puede tener éxito una vez – llamarla de nuevo falla porque la cuenta ya existe.
Deploy Wallet
Crea 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 structAccounts– necesario para usarwallet_iden los seeds - El relayer paga la renta por la nueva cuenta (~0.00114 SOL)
- El
factory_statees solo lectura aquí (sinmut) – solo necesitamos verificar sus campospausedyrelayer - Intentar desplegar el mismo
wallet_iddos veces falla porque el PDA ya existe
Sweep SOL
Esta 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 Token
Los 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
initcrea la cuenta, absorbiendo los lamports pre-fondeados en el balance de la nueva cuenta - El handler hace sweep inmediato de cualquier balance por encima de la exención de renta
Esto 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ón
Emergency Sweep SOL
El 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 Token
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
use crate::state::{FactoryState, WalletReceiver};
use crate::errors::WalletFactoryError;
pub fn handler(ctx: Context<EmergencySweepToken>, wallet_id: [u8; 32]) -> Result<()> {
let state = &ctx.accounts.factory_state;
require!(ctx.accounts.owner.key() == state.owner, WalletFactoryError::NotOwner);
let balance = ctx.accounts.source_token_account.amount;
require!(balance > 0, WalletFactoryError::NothingToSweep);
let wallet_id_ref = wallet_id;
let seeds = &[
WalletReceiver::SEED,
&wallet_id_ref,
&[ctx.accounts.wallet_receiver.bump],
];
let signer_seeds = &[&seeds[..]];
let cpi_accounts = Transfer {
from: ctx.accounts.source_token_account.to_account_info(),
to: ctx.accounts.destination_token_account.to_account_info(),
authority: ctx.accounts.wallet_receiver.to_account_info(),
};
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
signer_seeds,
);
token::transfer(cpi_ctx, balance)?;
emit!(EmergencyTokenSwept {
wallet_id,
receiver: ctx.accounts.wallet_receiver.key(),
mint: ctx.accounts.source_token_account.mint,
destination: ctx.accounts.destination_token_account.key(),
amount: balance,
});
Ok(())
}
#[derive(Accounts)]
#[instruction(wallet_id: [u8; 32])]
pub struct EmergencySweepToken<'info> {
#[account(
seeds = [FactoryState::SEED],
bump = factory_state.bump,
)]
pub factory_state: Account<'info, FactoryState>,
#[account(
seeds = [WalletReceiver::SEED, &wallet_id],
bump = wallet_receiver.bump,
)]
pub wallet_receiver: Account<'info, WalletReceiver>,
#[account(
mut,
constraint = source_token_account.owner == wallet_receiver.key(),
)]
pub source_token_account: Account<'info, TokenAccount>,
/// Destination token account (same mint, any owner)
#[account(mut)]
pub destination_token_account: Account<'info, TokenAccount>,
pub owner: Signer<'info>,
pub token_program: Program<'info, Token>,
}
#[event]
pub struct EmergencyTokenSwept {
pub wallet_id: [u8; 32],
pub receiver: Pubkey,
pub mint: Pubkey,
pub destination: Pubkey,
pub amount: u64,
}
Set Relayer
Actualiza 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 Unpause
Operaciones 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 reanudar
- Emisión de eventos para pausa/reanudación
- Un contador que registre cuántas veces se ha pausado la factory (para monitoreo)
Conceptos Clave de Solana Explicados
Exención de Renta
Cada 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 SOL
- El 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 disponibles
La 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 Seeds
Un 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 origen
- Llama a
invoke_signedcon los seeds que derivan el PDA - El runtime de Solana recalcula el PDA desde esos seeds + program ID
- Si el resultado coincide con la cuenta en la instrucción, la transferencia es autorizada
let seeds = &[
WalletReceiver::SEED, // b"wallet_receiver"
&wallet_id_ref, // [u8; 32]
&[ctx.accounts.wallet_receiver.bump], // canonical bump
];
let signer_seeds = &[&seeds[..]];
// This invoke_signed lets the PDA "sign" the transfer
anchor_lang::solana_program::program::invoke_signed(
&transfer_instruction,
&[source_account, destination_account, system_program],
signer_seeds,
)?;
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 Tokens
Las 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 Destinatarios
La 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ón
La 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 Tests
Setup: 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-Chain
Tu 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 SPL
Para 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 Lote
Para 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 Seguridad
Control de Acceso
El programa aplica control de acceso de dos niveles:
| Operación | Firmante Requerido | ¿Verifica Pausa? |
|---|---|---|
initialize | Owner (implícito, solo primera llamada) | No |
deploy_wallet | Relayer | Sí |
sweep_sol | Relayer | Sí |
sweep_token | Relayer | Sí |
deploy_and_sweep_sol | Relayer | Sí |
deploy_and_sweep_token | Relayer | Sí |
emergency_sweep_sol | Owner | No |
emergency_sweep_token | Owner | No |
set_relayer | Owner | No |
pause | Owner | No |
unpause | Owner | No |
Si la clave del relayer se ve comprometida:
- Llamar
pause()– bloquea inmediatamente todas las operaciones del relayer - Llamar
set_relayer(new_key)– rotar a una clave nueva - Usar
emergency_sweep_sol/emergency_sweep_tokenpara recuperar fondos en riesgo - Llamar
unpause()– reanudar operaciones con el nuevo relayer
Garantías de Exención de Renta
El 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 renta - En deploy_and_sweep_sol:
saturating_sub(min_rent)retorna 0 (salta el sweep) si está en el mínimo
Sin 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 PDA
Cada 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_receiverfue creada por este programa (verificación de discriminador) - La dirección de la cuenta coincide con el PDA esperado para el
wallet_iddado (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 BPS
Cada instrucción de sweep valida que:
- Se proporcione al menos 1 destinatario
- No 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,000
- Los BPS totales sumen exactamente 10,000
El 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 EVM
| Preocupación | Wallet Factory EVM | Wallet Factory Solana |
|---|---|---|
| Reentrancia | Riesgo mayor; usar ReentrancyGuard | No aplica (sin callbacks en system_program::transfer) |
| Front-running | Bots de MEV pueden hacer front-run a deploys | Los validadores pueden reordenar pero los PDAs no son sensibles a race conditions |
| Riesgo de upgrade de proxy | El patrón proxy introduce vectores de upgrade | Sin proxies; los upgrades del programa son separados de los datos |
| Ataques con selfdestruct | CREATE2 + selfdestruct para re-desplegar | No es posible; las cuentas PDA persisten |
| Exploits de aprobación de tokens | Patrón approve/transferFrom | El PDA es la autoridad; no se necesita aprobación |
| Gas griefing | El contrato destinatario puede consumir gas | Las cuentas destinatarias son pasivas; sin callback |
El 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 destinatario
- Sin cadenas de aprobación: el PDA posee directamente la cuenta de token y es la autoridad de transferencia
- Sin re-despliegue por self-destruct: una vez que una cuenta PDA existe, no puede ser destruida y recreada con estado diferente
Conclusión
Qué Construimos
Una wallet factory determinista completa en Solana que:
- Crea direcciones de depósito únicas a partir de un identificador de 32 bytes usando PDAs
- Distribuye SOL recibido a múltiples destinatarios basándose en puntos base
- Distribuye tokens SPL recibidos usando CPI al Token Program
- Soporta deploy-y-sweep atómico tanto para SOL como para tokens SPL
- Incluye recuperación de emergencia que ignora el mecanismo de pausa
- Aplica control de acceso de dos niveles (owner / relayer)
- Proporciona tests de integración en TypeScript y derivación de direcciones off-chain
Diferencias Clave con EVM
- Los PDAs reemplazan a CREATE2: Mismo determinismo, sin despliegue de bytecode, menor costo
- Exención de renta: No puedes hacer sweep del 100% del SOL – el mínimo de renta queda bloqueado
- Sin riesgo de reentrancia: Las transferencias del sistema y CPI de tokens no hacen callback a tu programa
- Modelo de cuentas: El estado está en cuentas, no en slots de storage de contratos
- Paso explícito de cuentas: Cada cuenta debe ser declarada en la transacción
- remaining_accounts: Las listas dinámicas de destinatarios usan arrays de cuentas sin tipo
Próximos Pasos
- Agregar soporte para Token-2022: El estándar de token más nuevo soporta fees de transferencia, transferencias confidenciales y más
- Implementar cierre de cuentas: Cerrar cuentas WalletReceiver para recuperar renta cuando ya no se necesiten
- Agregar verificación por lotes basada en Merkle: Procesar cientos de sweeps en una sola transacción usando pruebas comprimidas
- Desplegar en devnet/mainnet: Ejecutar
anchor deploy --provider.cluster devnety actualizar el program ID - Construir un dashboard de monitoreo: Suscribirse a eventos del programa (
WalletDeployed,SolSwept, etc.) para seguimiento en tiempo real - Agregar propiedad multisig: Usar un multisig de Squads como owner para despliegues en producción
El código fuente completo está disponible en el repositorio wallet-factory-multichain en GitHub.
Desarrollado por Beltsys Labs. Licencia MIT.


