
What Is an NFT? Complete Guide Updated for 2026
Originally published on beltsys.com

What Is DeFi? Complete Guide to Decentralized Finance in 2026
Originally published on beltsys.com

What Is a Token? Complete Guide to Crypto, Blockchain, and AI Tokens in 2026
Originally published on beltsys.com

What Is an NFT? Complete Guide Updated for 2026
Originally published on beltsys.com

What Is DeFi? Complete Guide to Decentralized Finance in 2026
Originally published on beltsys.com

What Is a Token? Complete Guide to Crypto, Blockchain, and AI Tokens in 2026
Originally published on beltsys.com

Subscribe to Beltsys Labs

Subscribe to Beltsys Labs
Share Dialog
Share Dialog
<100 subscribers
<100 subscribers


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.
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
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
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.
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:
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.
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
Aspecto | EVM CREATE2 | Solana PDA |
|---|---|---|
Determinismo |
|
|
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 |
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.
+-----------------------+
| 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
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].
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.
anchor init wallet-factory
cd wallet-factory
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-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 Anchor
crate-type = ["cdylib", "lib"] compila tanto una librería compartida (para on-chain) como una librería Rust (para tests/CPI)
[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.
{
"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:
programs/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)
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
}
Para FactoryState:
Campo | Tipo | Bytes |
|---|---|---|
Discriminador |
| 8 |
|
| 32 |
|
| 32 |
|
| 1 |
|
| 1 |
Total | 74 |
Para WalletReceiver:
Campo | Tipo | Bytes |
|---|---|---|
Discriminador |
| 8 |
|
| 32 |
|
| 32 |
|
| 1 |
|
| 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.
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.
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.
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 |
|---|---|---|
| 6000 | Un no-owner intenta una operación admin |
| 6001 | Un no-relayer intenta deploy/sweep |
| 6002 | Cualquier operación mientras la factory está pausada |
| 6003 | Se pasa Pubkey::default() como destinatario o relayer |
| 6004 | Más de 5 destinatarios en un sweep |
| 6005 | Los BPS de los destinatarios no suman 10,000 |
| 6006 | Un BPS individual de destinatario excede 10,000 |
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
}
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.
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::*;
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_account para asignar 74 bytes
El owner paga 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.
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 struct Accounts -- necesario para usar wallet_id en los seeds
El 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 relayer
Intentar desplegar el mismo wallet_id dos veces falla porque el PDA ya existe
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:
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).
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:
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.
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:
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 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.
La versión SPL token del deploy-y-sweep atómico:
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.
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.
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,
}
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.
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)
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.
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_signed con 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.
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.
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.
La suite de tests completa valida todos los flujos principales usando el framework de testing TypeScript de Anchor:
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.
# 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
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 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.
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.
El programa aplica control de acceso de dos niveles:
Operación | Firmante Requerido | ¿Verifica Pausa? |
|---|---|---|
| Owner (implícito, solo primera llamada) | No |
| Relayer | Sí |
| Relayer | Sí |
| Relayer | Sí |
| Relayer | Sí |
| Relayer | Sí |
| 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_token para recuperar fondos en riesgo
Llamar unpause() -- reanudar operaciones con el nuevo relayer
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.
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_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.
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.
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
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
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
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 devnet y 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.
Publicado originalmente en beltsys.com
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.
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
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
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.
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:
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.
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
Aspecto | EVM CREATE2 | Solana PDA |
|---|---|---|
Determinismo |
|
|
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 |
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.
+-----------------------+
| 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
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].
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.
anchor init wallet-factory
cd wallet-factory
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-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 Anchor
crate-type = ["cdylib", "lib"] compila tanto una librería compartida (para on-chain) como una librería Rust (para tests/CPI)
[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.
{
"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:
programs/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)
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
}
Para FactoryState:
Campo | Tipo | Bytes |
|---|---|---|
Discriminador |
| 8 |
|
| 32 |
|
| 32 |
|
| 1 |
|
| 1 |
Total | 74 |
Para WalletReceiver:
Campo | Tipo | Bytes |
|---|---|---|
Discriminador |
| 8 |
|
| 32 |
|
| 32 |
|
| 1 |
|
| 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.
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.
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.
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 |
|---|---|---|
| 6000 | Un no-owner intenta una operación admin |
| 6001 | Un no-relayer intenta deploy/sweep |
| 6002 | Cualquier operación mientras la factory está pausada |
| 6003 | Se pasa Pubkey::default() como destinatario o relayer |
| 6004 | Más de 5 destinatarios en un sweep |
| 6005 | Los BPS de los destinatarios no suman 10,000 |
| 6006 | Un BPS individual de destinatario excede 10,000 |
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
}
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.
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::*;
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_account para asignar 74 bytes
El owner paga 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.
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 struct Accounts -- necesario para usar wallet_id en los seeds
El 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 relayer
Intentar desplegar el mismo wallet_id dos veces falla porque el PDA ya existe
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:
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).
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:
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.
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:
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 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.
La versión SPL token del deploy-y-sweep atómico:
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.
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.
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,
}
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.
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)
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.
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_signed con 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.
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.
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.
La suite de tests completa valida todos los flujos principales usando el framework de testing TypeScript de Anchor:
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.
# 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
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 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.
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.
El programa aplica control de acceso de dos niveles:
Operación | Firmante Requerido | ¿Verifica Pausa? |
|---|---|---|
| Owner (implícito, solo primera llamada) | No |
| Relayer | Sí |
| Relayer | Sí |
| Relayer | Sí |
| Relayer | Sí |
| Relayer | Sí |
| 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_token para recuperar fondos en riesgo
Llamar unpause() -- reanudar operaciones con el nuevo relayer
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.
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_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.
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.
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
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
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
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 devnet y 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.
Publicado originalmente en beltsys.com
PublicKey.findProgramAddressSync() |
Máximo por programa | Ilimitado | Ilimitado (diferentes seeds) |
| 6007 | Array de destinatarios vacío |
| 6008 | La wallet tiene balance sweepable de cero |
| 6009 | Overflow aritmético en la distribución |
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,
}
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,
}
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>,
}
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)
}
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);
});
});
| Owner | No |
| Owner | No |
| Owner | No |
| Owner | No |
PublicKey.findProgramAddressSync() |
Máximo por programa | Ilimitado | Ilimitado (diferentes seeds) |
| 6007 | Array de destinatarios vacío |
| 6008 | La wallet tiene balance sweepable de cero |
| 6009 | Overflow aritmético en la distribución |
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,
}
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,
}
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>,
}
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)
}
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);
});
});
| Owner | No |
| Owner | No |
| Owner | No |
| Owner | No |
No activity yet