Beltsys Labs
Beltsys Labs
Avanzado Solana Anchor Rust

Wallet Factory Determinista en Solana con Anchor

Lucía
Lucía · Marketing & Contenidos
41 min de lectura
Wallet Factory Determinista en Solana con Anchor

Introducción

Las plataformas de procesamiento de pagos, exchanges y servicios custodiales enfrentan un desafío común: necesitan generar direcciones de depósito únicas para cada usuario y luego consolidar los fondos recibidos en una tesorería central. En cadenas EVM (Ethereum, Polygon, etc.), el enfoque estándar utiliza los opcodes CREATE2 o CREATE3 para desplegar contratos proxy ligeros en direcciones deterministas. En Solana, logramos el mismo resultado usando Program Derived Addresses (PDAs) – y el resultado es más elegante, más económico y posiblemente más seguro.

Este tutorial recorre la construcción de un programa completo de Wallet Factory en Solana usando el framework Anchor. El programa:

  • Despliega cuentas wallet receiver deterministas a partir de un identificador de 32 bytes
  • Hace sweep de SOL nativo con distribución en puntos base (BPS) a múltiples destinatarios
  • Hace sweep de tokens SPL con el mismo modelo de distribución por BPS
  • Soporta deploy-y-sweep atómico en una sola transacción
  • Incluye recuperación de emergencia, pausa/reanudación y gestión de relayer

Al finalizar, tendrás un programa Solana de nivel productivo con tests de integración completos en TypeScript. La arquitectura se traduce directamente de los patrones de wallet factory en EVM, por lo que si estás migrando desde Ethereum, esta guía conecta la brecha conceptual.

¿Para quién es esto?

  • Desarrolladores de Solana construyendo infraestructura de pagos
  • Desarrolladores EVM migrando patrones de wallet factory a Solana
  • Equipos construyendo sistemas de depósito custodiales o semi-custodiales
  • Cualquier persona que quiera entender PDAs, CPIs y patrones de Anchor en profundidad

Prerrequisitos

  • Rust y Cargo instalados
  • Herramientas CLI de Solana (v1.18+)
  • Node.js 18+ y npm
  • Anchor CLI (v0.30.1)
  • Familiaridad básica con el modelo de cuentas de Solana

Cómo Funcionan los PDAs en Solana

El Problema

Necesitas generar una dirección de depósito para el usuario #47291. En Ethereum, desplegarías un contrato proxy mínimo en una dirección determinista usando CREATE2(salt, bytecodeHash). La dirección es calculable off-chain antes del despliegue.

En Solana, no existen “contratos” como tal – hay programas (código sin estado) y cuentas (estado). No puedes “desplegar un contrato por usuario.” En su lugar, derivas una Program Derived Address (PDA) que es única, determinista y controlada por tu programa.

Seeds y Bumps

Un PDA se deriva de:

  1. Seeds – arrays de bytes arbitrarios que eliges (ej., "wallet_receiver" + wallet_id)
  2. Program ID – el programa que “posee” el PDA
  3. 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_idbumpprogram_id)

Anchor encuentra el bump válido más alto automáticamente (el “bump canónico”). Como la dirección está fuera de la curva, no existe clave privada para ella – solo el programa puede firmar por esta cuenta.

Por Qué los PDAs Son Deterministas

Dados los mismos seeds y program ID, siempre obtienes la misma dirección. Esto significa:

  • Tu backend puede calcular la dirección de depósito antes de que exista on-chain
  • Los usuarios pueden enviar SOL o tokens a la dirección del PDA inmediatamente
  • El programa puede luego “desplegar” la cuenta y hacer sweep de fondos en una sola transacción

EVM CREATE2 vs Solana PDA

AspectoEVM CREATE2Solana PDA
Determinismokeccak256(0xff, deployer, salt, initCodeHash)SHA256(seeds, bump, programId)
Costo de creaciónDesplegar contrato (~32,000+ gas)Crear cuenta (~0.002 SOL de renta)
Código en la direcciónSí (bytecode del proxy)No (el PDA es solo una cuenta)
Puede recibir antes del deploySí (solo ETH, no ERC-20)Sí (SOL y tokens SPL)
RedespliegableSolo con selfdestruct (deprecado)No (la cuenta persiste)
AutoridadLógica del owner/contratoPrograma que lo derivó
Cálculo off-chainethers.getCreate2Address()PublicKey.findProgramAddressSync()
Máximo por programaIlimitadoIlimitado (diferentes seeds)

La ventaja clave en Solana: los PDAs pueden recibir tanto SOL como tokens SPL antes de que la cuenta sea inicializada por el programa. No existe una limitación equivalente a los tokens ERC-20 que requieren un contrato desplegado.

Visión General de la Arquitectura

s"efsFS(eatatPdcacaDsttttA:oeoe)r"ryiy_nitial"+iswrzWRPeaeweaeDelcalcAdlel(/W(leseilBTaRei#:tveaydlutv1_etcpelser_kepetr"ieSltdncoF/_drya1icA/p/tntocRsrh"+ecwyoswrllerWRPeaewaieP)aeDelcayeprlcAdlelenoleseilrt/gei#:tve)rtv2_etaaer_dmr"imdi_n2"+WRPswraeDeaewlcAelcaledlelei#seiltvN:tvee_etrr_"id_N

Dos Tipos de Cuenta

FactoryState (PDA singleton) – Uno por despliegue de programa. Almacena el owner, relayer, flag de pausa y bump del PDA. Seeds: ["factory_state"].

WalletReceiver (PDA por wallet) – Uno por dirección de depósito. Almacena el wallet_id, referencia a la factory padre, flag de inicialización y bump. Seeds: ["wallet_receiver", wallet_id].

Control de Acceso Basado en Roles

  • Owner – El deployer. Puede pausar/reanudar la factory, cambiar el relayer y ejecutar sweeps de emergencia (ignora la pausa).
  • Relayer – Una clave de backend automatizada. Puede desplegar wallets y ejecutar sweeps regulares. No puede modificar el estado de la factory ni hacer sweeps de emergencia.

Esta separación significa que si tu clave hot del relayer se ve comprometida, el radio de explosión es limitado.

Configuración del Proyecto

Inicializar el Proyecto Anchor

anchor init wallet-factory
cd wallet-factory

Cargo.toml

El Cargo.toml del programa define las dependencias de Anchor y la librería SPL Token:

[package]
name = "wallet_factory"
version = "0.1.0"
description = "Solana Multi-chain Wallet Factory"
edition = "2021"

[lib]
crate-type = ["cdylib", "lib"]
name = "wallet_factory"

[features]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
cpi = ["no-entrypoint"]
default = []

[dependencies]
anchor-lang = "0.30.1"
anchor-spl = "0.30.1"
solana-program = "~1.18"
winnow = "=0.4.1"        # Fix for dependency issues in some environments

Puntos clave:

  • anchor-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)

Anchor.toml

[toolchain]

[features]
resolution = true
skip-lint = false

[programs.localnet]
wallet_factory = "Bv3xMqy2kyu7RX3Vzi9PVgo12UoWcLLhWN71kcmTQRFF"

[registry]
url = "https://api.apr.dev"

[provider]
cluster = "Localnet"
wallet = "~/.config/solana/id.json"

[scripts]
test = "npm run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"

Reemplaza el program ID con el generado por anchor keys list después de tu primer build.

package.json (Dependencias de Test)

{
  "name": "wallet-factory",
  "version": "0.1.0",
  "description": "Solana Multi-chain Wallet Factory Tests",
  "scripts": {
    "lint:fix": "prettier */*.js* */*.ts* --write",
    "lint": "prettier */*.js* */*.ts* --check"
  },
  "dependencies": {
    "@coral-xyz/anchor": "^0.29.0",
    "@solana/spl-token": "^0.3.9",
    "@solana/web3.js": "^1.87.6"
  },
  "devDependencies": {
    "chai": "^4.3.4",
    "mocha": "^9.0.3",
    "ts-mocha": "^10.0.0",
    "typescript": "^5.0.0",
    "prettier": "^2.6.2"
  }
}

Instalar dependencias:

npm install

Estructura de Archivos

ptreolesiswgirtntarbramsmidssddeespusla.ototonewweemmean/lmrredrdipeeppeetupesss.u.tleellrr_sat/.rcrioppooggreu-wrstsay__yyeee.sfasil_st__nnlrealoiwooaaccas.clnzalknnyyyrtesel.edd__esot/.lrn__ssrr_res.ssww.yfstrwweer.a.seeeestcreeppstspp__o__strstooyoolk/lk.es.ernrrns.cs.r/rss################PCFMCCSSAAOOUPUIruaorrwwttwwpannoscdeeeeoonnduptgttuaaeemmeeasaeroolttppiirrteugamreeecc--esrmySSooteaeSrFWOPddnnrhterteaaLLeelleetinra-clppyylhototetlwtllafenrrexoeioooStyayprttkyyOoecftc+oyRheLkrtaeporSen++eocsodWttcBsrnprttieasaePsseuyosnsltiSwwwcrbrtlevieeoeky(,eedteevceTtrihppeoyyiRsrvpnePtBSSyeescDrPOPrSteAiSLLycribruvudticetioptrisktiote)oanrnnciscbdoueutcniltoanrsattriuocntss

Cuentas de Estado

El módulo de estado define las dos cuentas on-chain. Cada cuenta en Anchor tiene un discriminador de 8 bytes (SHA256 del nombre de la cuenta) antepuesto automáticamente.

use anchor_lang::prelude::*;

/// Global factory state (singleton PDA)
#[account]
#[derive(Debug)]
pub struct FactoryState {
    /// Owner (can pause/unpause, change relayer, emergency sweep)
    pub owner: Pubkey,
    /// Relayer (can deploy wallets and sweep)
    pub relayer: Pubkey,
    /// Whether the factory is paused
    pub paused: bool,
    /// Bump for the PDA
    pub bump: u8,
}

impl FactoryState {
    pub const SEED: &'static [u8] = b"factory_state";
    pub const LEN: usize = 8 + 32 + 32 + 1 + 1; // discriminator + owner + relayer + paused + bump
}

/// Per-wallet PDA. Stores metadata; the PDA itself receives SOL/tokens.
#[account]
#[derive(Debug)]
pub struct WalletReceiver {
    /// The wallet_id that was used as seed (bytes32 equivalent)
    pub wallet_id: [u8; 32],
    /// Factory that deployed this wallet
    pub factory: Pubkey,
    /// Whether this wallet has been deployed (always true once created)
    pub initialized: bool,
    /// Bump for this PDA
    pub bump: u8,
}

impl WalletReceiver {
    pub const SEED: &'static [u8] = b"wallet_receiver";
    pub const LEN: usize = 8 + 32 + 32 + 1 + 1; // discriminator + wallet_id + factory + initialized + bump
}

Desglose del Cálculo de Espacio

Para FactoryState:

CampoTipoBytes
Discriminador[u8; 8]8
ownerPubkey32
relayerPubkey32
pausedbool1
bumpu81
Total74

Para WalletReceiver:

CampoTipoBytes
Discriminador[u8; 8]8
wallet_id[u8; 32]32
factoryPubkey32
initializedbool1
bumpu81
Total74

Ambas cuentas tienen exactamente 74 bytes. A las tasas actuales de renta (~6.96 lamports por byte-época), el mínimo exento de renta para una cuenta de 74 bytes es aproximadamente 0.00114 SOL.

¿Por Qué Almacenar el Bump?

El bump del PDA se almacena on-chain para que las instrucciones posteriores no necesiten recalcularlo. Cuando accedes a wallet_receiver.bump, te ahorras el costo de find_program_address en el runtime. La restricción bump = factory_state.bump de Anchor usa el valor almacenado para verificación.

Diseño de Seeds

El seed b"factory_state" es un prefijo estático – solo puede existir un FactoryState por programa. El seed b"wallet_receiver" combinado con wallet_id (un array de 32 bytes) crea un PDA único por wallet. El wallet_id se mapea a cualquier identificador que use tu backend – un UUID, hash de ID de usuario o número de orden. Usar 32 bytes (igual que un bytes32 en Solidity) hace que el mapeo de IDs entre cadenas sea directo.

Definición de Errores

Los errores personalizados proporcionan mensajes de fallo claros y códigos de error distintos para el manejo del lado del cliente:

use anchor_lang::prelude::*;

#[error_code]
pub enum WalletFactoryError {
    #[msg("Not authorized: signer is not the owner")]
    NotOwner,

    #[msg("Not authorized: signer is not the relayer")]
    NotRelayer,

    #[msg("Factory is currently paused")]
    Paused,

    #[msg("Zero address / pubkey not allowed")]
    ZeroAddress,

    #[msg("Too many recipients (max 5)")]
    TooManyRecipients,

    #[msg("BPS values must sum to exactly 10000")]
    BpsDoNotSum,

    #[msg("Individual BPS value exceeds 10000")]
    BpsOverflow,

    #[msg("No recipients provided")]
    InvalidRecipients,

    #[msg("Nothing to sweep: balance is zero")]
    NothingToSweep,

    #[msg("Arithmetic overflow")]
    Overflow,
}

Cada variante se convierte en un código de error de Anchor (6000 + índice). Tu cliente TypeScript puede hacer match sobre estos códigos:

ErrorCódigoCuándo
NotOwner6000Un no-owner intenta una operación admin
NotRelayer6001Un no-relayer intenta deploy/sweep
Paused6002Cualquier operación mientras la factory está pausada
ZeroAddress6003Se pasa Pubkey::default() como destinatario o relayer
TooManyRecipients6004Más de 5 destinatarios en un sweep
BpsDoNotSum6005Los BPS de los destinatarios no suman 10,000
BpsOverflow6006Un BPS individual de destinatario excede 10,000
InvalidRecipients6007Array de destinatarios vacío
NothingToSweep6008La wallet tiene balance sweepable de cero
Overflow6009Overflow aritmético en la distribución

Punto de Entrada del Programa

El archivo lib.rs declara el program ID, todas las instrucciones y la struct Recipient usada en las operaciones de sweep:

use anchor_lang::prelude::*;

pub mod errors;
pub mod instructions;
pub mod state;

use instructions::*;

declare_id!("Bv3xMqy2kyu7RX3Vzi9PVgo12UoWcLLhWN71kcmTQRFF");

#[program]
pub mod wallet_factory {
    use super::*;

    /// Initialize the factory (owner + relayer)
    pub fn initialize(ctx: Context<Initialize>, relayer: Pubkey) -> Result<()> {
        instructions::initialize::handler(ctx, relayer)
    }

    /// Admin: update relayer
    pub fn set_relayer(ctx: Context<SetRelayer>, new_relayer: Pubkey) -> Result<()> {
        instructions::set_relayer::handler(ctx, new_relayer)
    }

    /// Admin: pause the factory
    pub fn pause(ctx: Context<Pause>) -> Result<()> {
        instructions::pause::handler(ctx)
    }

    /// Admin: unpause the factory
    pub fn unpause(ctx: Context<Unpause>) -> Result<()> {
        instructions::unpause::handler(ctx)
    }

    /// Relayer: deploy a new WalletReceiver PDA for a given wallet_id
    pub fn deploy_wallet(ctx: Context<DeployWallet>, wallet_id: [u8; 32]) -> Result<()> {
        instructions::deploy_wallet::handler(ctx, wallet_id)
    }

    /// Relayer: sweep SOL from a wallet receiver PDA to multiple recipients (by BPS)
    pub fn sweep_sol(
        ctx: Context<SweepSol>,
        wallet_id: [u8; 32],
        recipients: Vec<Recipient>,
    ) -> Result<()> {
        instructions::sweep_sol::handler(ctx, wallet_id, recipients)
    }

    /// Relayer: sweep SPL tokens from a wallet receiver PDA to multiple recipients (by BPS)
    pub fn sweep_token(
        ctx: Context<SweepToken>,
        wallet_id: [u8; 32],
        recipients: Vec<Recipient>,
    ) -> Result<()> {
        instructions::sweep_token::handler(ctx, wallet_id, recipients)
    }

    /// Relayer: deploy + sweep SOL atomically
    pub fn deploy_and_sweep_sol(
        ctx: Context<DeployAndSweepSol>,
        wallet_id: [u8; 32],
        recipients: Vec<Recipient>,
    ) -> Result<()> {
        instructions::deploy_and_sweep_sol::handler(ctx, wallet_id, recipients)
    }

    /// Relayer: deploy + sweep SPL token atomically
    pub fn deploy_and_sweep_token(
        ctx: Context<DeployAndSweepToken>,
        wallet_id: [u8; 32],
        recipients: Vec<Recipient>,
    ) -> Result<()> {
        instructions::deploy_and_sweep_token::handler(ctx, wallet_id, recipients)
    }

    /// Owner: emergency sweep SOL to a single destination
    pub fn emergency_sweep_sol(
        ctx: Context<EmergencySweepSol>,
        wallet_id: [u8; 32],
    ) -> Result<()> {
        instructions::emergency_sweep_sol::handler(ctx, wallet_id)
    }

    /// Owner: emergency sweep SPL token to a single destination
    pub fn emergency_sweep_token(
        ctx: Context<EmergencySweepToken>,
        wallet_id: [u8; 32],
    ) -> Result<()> {
        instructions::emergency_sweep_token::handler(ctx, wallet_id)
    }
}

/// Recipient struct (BPS-based distribution)
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct Recipient {
    pub wallet: Pubkey,
    pub bps: u16, // basis points, max 10_000
}

Decisiones de Diseño

lib.rs delgado: Cada instrucción delega a su propio archivo en el módulo instructions/. Esto mantiene el punto de entrada legible y cada instrucción autocontenida con su función handler y struct Accounts.

Recipient en la raíz del crate: La struct Recipient es usada por seis instrucciones diferentes. Colocarla en lib.rs evita importaciones circulares entre módulos de instrucciones.

wallet_id como [u8; 32]: Un array de bytes de tamaño fijo coincide con el tipo bytes32 de Solidity, haciendo trivial el mapeo de IDs entre cadenas. Tu backend genera un identificador único de 32 bytes por dirección de depósito.

BPS (Basis Points): 10,000 BPS = 100%. Esto permite divisiones como 70/30 (7000/3000) o 50/25/25 (5000/2500/2500) sin matemática de punto flotante.

Módulo de Instrucciones

El archivo del módulo re-exporta todos los tipos de instrucciones:

pub mod initialize;
pub mod set_relayer;
pub mod pause;
pub mod unpause;
pub mod deploy_wallet;
pub mod sweep_sol;
pub mod sweep_token;
pub mod deploy_and_sweep_sol;
pub mod deploy_and_sweep_token;
pub mod emergency_sweep_sol;
pub mod emergency_sweep_token;

pub use initialize::*;
pub use set_relayer::*;
pub use pause::*;
pub use unpause::*;
pub use deploy_wallet::*;
pub use sweep_sol::*;
pub use sweep_token::*;
pub use deploy_and_sweep_sol::*;
pub use deploy_and_sweep_token::*;
pub use emergency_sweep_sol::*;
pub use emergency_sweep_token::*;

Instrucciones Principales

Initialize

Crea el PDA singleton FactoryState, estableciendo al llamante como owner y asignando el relayer:

use anchor_lang::prelude::*;
use crate::state::FactoryState;
use crate::errors::WalletFactoryError;

pub fn handler(ctx: Context<Initialize>, relayer: Pubkey) -> Result<()> {
    require!(relayer != Pubkey::default(), WalletFactoryError::ZeroAddress);

    let state = &mut ctx.accounts.factory_state;
    state.owner = ctx.accounts.owner.key();
    state.relayer = relayer;
    state.paused = false;
    state.bump = ctx.bumps.factory_state;

    emit!(RelayerUpdated {
        old_relayer: Pubkey::default(),
        new_relayer: relayer,
    });

    Ok(())
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = owner,
        space = FactoryState::LEN,
        seeds = [FactoryState::SEED],
        bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    #[account(mut)]
    pub owner: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[event]
pub struct RelayerUpdated {
    pub old_relayer: Pubkey,
    pub new_relayer: Pubkey,
}

Lo que sucede internamente:

  1. Anchor deriva el PDA desde ["factory_state"] + el program ID
  2. Llama a system_program::create_account para asignar 74 bytes
  3. El owner paga los lamports exentos de renta
  4. El discriminador se escribe automáticamente (primeros 8 bytes)
  5. Nuestro handler rellena los campos restantes

La restricción init asegura que esta instrucción solo puede tener éxito una vez – llamarla de nuevo falla porque la cuenta ya existe.

Deploy Wallet

Crea un nuevo PDA WalletReceiver para un wallet_id dado. Este es el equivalente en Solana de desplegar un proxy CREATE2:

use anchor_lang::prelude::*;
use crate::state::{FactoryState, WalletReceiver};
use crate::errors::WalletFactoryError;

pub fn handler(ctx: Context<DeployWallet>, wallet_id: [u8; 32]) -> Result<()> {
    let state = &ctx.accounts.factory_state;
    require!(!state.paused, WalletFactoryError::Paused);
    require!(ctx.accounts.relayer.key() == state.relayer, WalletFactoryError::NotRelayer);

    let receiver = &mut ctx.accounts.wallet_receiver;
    receiver.wallet_id = wallet_id;
    receiver.factory = ctx.accounts.factory_state.key();
    receiver.initialized = true;
    receiver.bump = ctx.bumps.wallet_receiver;

    emit!(WalletDeployed {
        wallet_id,
        receiver: ctx.accounts.wallet_receiver.key(),
    });

    Ok(())
}

#[derive(Accounts)]
#[instruction(wallet_id: [u8; 32])]
pub struct DeployWallet<'info> {
    #[account(
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    /// WalletReceiver PDA — deterministic from wallet_id (equivalent to CREATE2)
    #[account(
        init,
        payer = relayer,
        space = WalletReceiver::LEN,
        seeds = [WalletReceiver::SEED, &wallet_id],
        bump,
    )]
    pub wallet_receiver: Account<'info, WalletReceiver>,

    #[account(mut)]
    pub relayer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[event]
pub struct WalletDeployed {
    pub wallet_id: [u8; 32],
    pub receiver: Pubkey,
}

Detalles clave:

  • El atributo #[instruction(wallet_id: [u8; 32])] permite a Anchor acceder a los argumentos de la instrucción en la 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

Sweep SOL

Esta es la instrucción principal más compleja. Hace sweep de SOL desde un PDA WalletReceiver a múltiples destinatarios basándose en puntos base, mientras preserva el mínimo exento de renta:

use anchor_lang::prelude::*;
use anchor_lang::system_program;
use crate::state::{FactoryState, WalletReceiver};
use crate::errors::WalletFactoryError;
use crate::Recipient;

/// Helper: validate recipients and compute per-recipient amounts
fn validate_and_compute(balance: u64, recipients: &[Recipient]) -> Result<Vec<u64>> {
    require!(!recipients.is_empty(), WalletFactoryError::InvalidRecipients);
    require!(recipients.len() <= 5, WalletFactoryError::TooManyRecipients);

    let mut total_bps: u64 = 0;
    for r in recipients {
        require!(r.wallet != Pubkey::default(), WalletFactoryError::ZeroAddress);
        require!(r.bps <= 10_000, WalletFactoryError::BpsOverflow);
        total_bps += r.bps as u64;
    }
    require!(total_bps == 10_000, WalletFactoryError::BpsDoNotSum);
    require!(balance > 0, WalletFactoryError::NothingToSweep);

    let mut amounts = Vec::with_capacity(recipients.len());
    let mut distributed: u64 = 0;
    for (i, r) in recipients.iter().enumerate() {
        let amount = if i == recipients.len() - 1 {
            balance - distributed
        } else {
            (balance as u128 * r.bps as u128 / 10_000) as u64
        };
        distributed += amount;
        amounts.push(amount);
    }
    Ok(amounts)
}

pub fn handler(
    ctx: Context<SweepSol>,
    wallet_id: [u8; 32],
    recipients: Vec<Recipient>,
) -> Result<()> {
    let state = &ctx.accounts.factory_state;
    require!(!state.paused, WalletFactoryError::Paused);
    require!(ctx.accounts.relayer.key() == state.relayer, WalletFactoryError::NotRelayer);

    // Sweepable balance = lamports above rent exemption (the PDA data account balance)
    let receiver_info = ctx.accounts.wallet_receiver.to_account_info();
    let rent = Rent::get()?;
    let min_rent = rent.minimum_balance(WalletReceiver::LEN);
    let balance = receiver_info
        .lamports()
        .checked_sub(min_rent)
        .ok_or(WalletFactoryError::NothingToSweep)?;

    let amounts = validate_and_compute(balance, &recipients)?;

    let wallet_id_ref = wallet_id;
    let seeds = &[
        WalletReceiver::SEED,
        &wallet_id_ref,
        &[ctx.accounts.wallet_receiver.bump],
    ];
    let signer_seeds = &[&seeds[..]];

    for (i, r) in recipients.iter().enumerate() {
        if amounts[i] > 0 {
            let ix = anchor_lang::solana_program::system_instruction::transfer(
                &ctx.accounts.wallet_receiver.key(),
                &r.wallet,
                amounts[i],
            );
            anchor_lang::solana_program::program::invoke_signed(
                &ix,
                &[
                    ctx.accounts.wallet_receiver.to_account_info(),
                    ctx.accounts.system_program.to_account_info(),
                ],
                signer_seeds,
            )?;
        }
    }

    emit!(SolSwept {
        wallet_id,
        receiver: ctx.accounts.wallet_receiver.key(),
        total_amount: balance,
        recipient_count: recipients.len() as u8,
    });

    Ok(())
}

#[derive(Accounts)]
#[instruction(wallet_id: [u8; 32])]
pub struct SweepSol<'info> {
    #[account(
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    #[account(
        mut,
        seeds = [WalletReceiver::SEED, &wallet_id],
        bump = wallet_receiver.bump,
    )]
    pub wallet_receiver: Account<'info, WalletReceiver>,

    pub relayer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[event]
pub struct SolSwept {
    pub wallet_id: [u8; 32],
    pub receiver: Pubkey,
    pub total_amount: u64,
    pub recipient_count: u8,
}

Conceptos críticos en esta instrucción:

Exención de renta: En Solana, las cuentas deben mantener un balance mínimo de lamports para evitar ser recolectadas por el garbage collector. El balance sweepable es total_lamports - mínimo_exento_de_renta. No puedes hacer sweep del balance completo sin cerrar la cuenta. Esto es fundamentalmente diferente de EVM donde el balance de ETH está completamente disponible.

Firma de PDA con invoke_signed: Los PDAs no tienen clave privada. Para transferir SOL desde un PDA, construyes una instrucción de sistema y llamas a invoke_signed con los seeds + bump del PDA. El runtime verifica que los seeds deriven la dirección correcta y permite la transferencia.

Distribución libre de dust: El último destinatario recibe balance - distributed en lugar de un cálculo por BPS. Esto asegura que hasta el último lamport sea distribuido y evita que se acumule dust de redondeo en el PDA.

Matemática intermedia en u128: El cálculo de BPS hace cast a u128 antes de multiplicar: (balance as u128 * bps as u128 / 10_000) as u64. Esto previene overflow cuando balance * bps excede u64::MAX (lo cual ocurre por encima de ~1.8 mil millones de SOL – improbable pero defensivo).

Sweep Token

Los sweeps de tokens SPL siguen un patrón similar pero usan Cross-Program Invocations (CPI) al programa Token en lugar de transferencias del sistema. Las cuentas de token destino se pasan a través de remaining_accounts:

use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
use crate::state::{FactoryState, WalletReceiver};
use crate::errors::WalletFactoryError;
use crate::Recipient;

pub fn handler(
    ctx: Context<SweepToken>,
    wallet_id: [u8; 32],
    recipients: Vec<Recipient>,
) -> Result<()> {
    let state = &ctx.accounts.factory_state;
    require!(!state.paused, WalletFactoryError::Paused);
    require!(ctx.accounts.relayer.key() == state.relayer, WalletFactoryError::NotRelayer);

    require!(!recipients.is_empty(), WalletFactoryError::InvalidRecipients);
    require!(recipients.len() <= 5, WalletFactoryError::TooManyRecipients);

    let balance = ctx.accounts.source_token_account.amount;
    require!(balance > 0, WalletFactoryError::NothingToSweep);

    let mut total_bps: u64 = 0;
    for r in &recipients {
        require!(r.wallet != Pubkey::default(), WalletFactoryError::ZeroAddress);
        require!(r.bps <= 10_000, WalletFactoryError::BpsOverflow);
        total_bps += r.bps as u64;
    }
    require!(total_bps == 10_000, WalletFactoryError::BpsDoNotSum);

    let wallet_id_ref = wallet_id;
    let seeds = &[
        WalletReceiver::SEED,
        &wallet_id_ref,
        &[ctx.accounts.wallet_receiver.bump],
    ];
    let signer_seeds = &[&seeds[..]];

    let mut distributed: u64 = 0;
    let n = recipients.len();
    for (i, r) in recipients.iter().enumerate() {
        let amount = if i == n - 1 {
            balance - distributed
        } else {
            (balance as u128 * r.bps as u128 / 10_000) as u64
        };
        distributed += amount;

        if amount > 0 {
            // Each recipient_token_account is passed in remaining_accounts
            // Index i maps to remaining_accounts[i]
            let dest_account = &ctx.remaining_accounts[i];

            let cpi_accounts = Transfer {
                from: ctx.accounts.source_token_account.to_account_info(),
                to: dest_account.clone(),
                authority: ctx.accounts.wallet_receiver.to_account_info(),
            };
            let cpi_ctx = CpiContext::new_with_signer(
                ctx.accounts.token_program.to_account_info(),
                cpi_accounts,
                signer_seeds,
            );
            token::transfer(cpi_ctx, amount)?;
        }
    }

    emit!(TokenSwept {
        wallet_id,
        receiver: ctx.accounts.wallet_receiver.key(),
        mint: ctx.accounts.source_token_account.mint,
        total_amount: balance,
        recipient_count: n as u8,
    });

    Ok(())
}

#[derive(Accounts)]
#[instruction(wallet_id: [u8; 32])]
pub struct SweepToken<'info> {
    #[account(
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    #[account(
        seeds = [WalletReceiver::SEED, &wallet_id],
        bump = wallet_receiver.bump,
    )]
    pub wallet_receiver: Account<'info, WalletReceiver>,

    /// Token account owned by the wallet_receiver PDA
    #[account(
        mut,
        constraint = source_token_account.owner == wallet_receiver.key(),
    )]
    pub source_token_account: Account<'info, TokenAccount>,

    pub relayer: Signer<'info>,

    pub token_program: Program<'info, Token>,

    // remaining_accounts: Vec of destination TokenAccounts (one per recipient)
}

#[event]
pub struct TokenSwept {
    pub wallet_id: [u8; 32],
    pub receiver: Pubkey,
    pub mint: Pubkey,
    pub total_amount: u64,
    pub recipient_count: u8,
}

Diferencias clave respecto al sweep de SOL:

Sin preocupación por exención de renta para tokens: El balance de la cuenta SPL Token es la cantidad total de tokens (la exención de renta aplica al SOL en la cuenta de token, no al balance de tokens en sí). Así que hacemos sweep del source_token_account.amount completo.

CPI al programa Token: En lugar de invoke_signed con una instrucción de transferencia del sistema, usamos token::transfer de Anchor con CpiContext::new_with_signer. Esto crea una Cross-Program Invocation donde el PDA WalletReceiver firma como la autoridad del token.

remaining_accounts para destinatarios dinámicos: La struct Accounts de Anchor requiere cuentas definidas estáticamente. Como el número de destinatarios varía (1-5), las cuentas de token destino se pasan a través de ctx.remaining_accounts. El índice i en el vector de destinatarios se mapea a remaining_accounts[i].

Restricción de propiedad del origen: La constraint = source_token_account.owner == wallet_receiver.key() asegura que la cuenta de token realmente pertenezca al PDA. Sin esto, alguien podría pasar una cuenta de token arbitraria.

Deploy y Sweep SOL (Atómico)

Esta instrucción combina el despliegue de wallet y el sweep de SOL en una sola transacción atómica. Crítica para el caso donde quieres desplegar e inmediatamente hacer sweep de lamports pre-fondeados:

use anchor_lang::prelude::*;
use crate::state::{FactoryState, WalletReceiver};
use crate::errors::WalletFactoryError;
use crate::Recipient;

pub fn handler(
    ctx: Context<DeployAndSweepSol>,
    wallet_id: [u8; 32],
    recipients: Vec<Recipient>,
) -> Result<()> {
    let state = &ctx.accounts.factory_state;
    require!(!state.paused, WalletFactoryError::Paused);
    require!(ctx.accounts.relayer.key() == state.relayer, WalletFactoryError::NotRelayer);

    // Initialize wallet receiver
    let receiver = &mut ctx.accounts.wallet_receiver;
    receiver.wallet_id = wallet_id;
    receiver.factory = ctx.accounts.factory_state.key();
    receiver.initialized = true;
    receiver.bump = ctx.bumps.wallet_receiver;

    emit!(crate::instructions::deploy_wallet::WalletDeployed {
        wallet_id,
        receiver: ctx.accounts.wallet_receiver.key(),
    });

    // Sweep SOL if any above rent
    let receiver_info = ctx.accounts.wallet_receiver.to_account_info();
    let rent = Rent::get()?;
    let min_rent = rent.minimum_balance(WalletReceiver::LEN);
    let balance = receiver_info.lamports().saturating_sub(min_rent);

    if balance > 0 {
        require!(!recipients.is_empty(), WalletFactoryError::InvalidRecipients);
        require!(recipients.len() <= 5, WalletFactoryError::TooManyRecipients);

        let mut total_bps: u64 = 0;
        for r in &recipients {
            require!(r.wallet != Pubkey::default(), WalletFactoryError::ZeroAddress);
            require!(r.bps <= 10_000, WalletFactoryError::BpsOverflow);
            total_bps += r.bps as u64;
        }
        require!(total_bps == 10_000, WalletFactoryError::BpsDoNotSum);

        let wallet_id_ref = wallet_id;
        let seeds = &[
            WalletReceiver::SEED,
            &wallet_id_ref,
            &[ctx.accounts.wallet_receiver.bump],
        ];
        let signer_seeds = &[&seeds[..]];

        let mut distributed: u64 = 0;
        let n = recipients.len();
        for (i, r) in recipients.iter().enumerate() {
            let amount = if i == n - 1 {
                balance - distributed
            } else {
                (balance as u128 * r.bps as u128 / 10_000) as u64
            };
            distributed += amount;

            if amount > 0 {
                let ix = anchor_lang::solana_program::system_instruction::transfer(
                    &ctx.accounts.wallet_receiver.key(),
                    &r.wallet,
                    amount,
                );
                anchor_lang::solana_program::program::invoke_signed(
                    &ix,
                    &[
                        ctx.accounts.wallet_receiver.to_account_info(),
                        ctx.accounts.system_program.to_account_info(),
                    ],
                    signer_seeds,
                )?;
            }
        }

        emit!(crate::instructions::sweep_sol::SolSwept {
            wallet_id,
            receiver: ctx.accounts.wallet_receiver.key(),
            total_amount: balance,
            recipient_count: n as u8,
        });
    }

    Ok(())
}

#[derive(Accounts)]
#[instruction(wallet_id: [u8; 32])]
pub struct DeployAndSweepSol<'info> {
    #[account(
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    #[account(
        init,
        payer = relayer,
        space = WalletReceiver::LEN,
        seeds = [WalletReceiver::SEED, &wallet_id],
        bump,
    )]
    pub wallet_receiver: Account<'info, WalletReceiver>,

    #[account(mut)]
    pub relayer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

Por qué lo atómico importa:

En Solana, puedes enviar SOL a una dirección PDA antes de que la cuenta sea inicializada por el programa. Los lamports quedan en esa dirección. Cuando deploy_and_sweep_sol se ejecuta:

  1. La restricción init crea la cuenta, absorbiendo los lamports pre-fondeados en el balance de la nueva cuenta
  2. El handler hace sweep inmediato de cualquier balance por encima de la exención de renta

Esto significa que un usuario puede depositar SOL, y tu backend puede desplegar + hacer sweep en una transacción – una firma, una confirmación de bloque. En el mundo EVM, esto requeriría dos transacciones (desplegar proxy, luego llamar sweep).

saturating_sub vs checked_sub: La versión atómica usa saturating_sub en lugar de checked_sub. Si el PDA fue recién creado con exactamente la cantidad de renta (sin pre-fondeo), balance se vuelve 0 y el bloque de sweep se salta elegantemente. El sweep_sol independiente usa checked_sub y retorna un error porque llamar sweep sin nada que hacer sweep es inesperado.

Deploy y Sweep Token (Atómico)

La versión SPL token del deploy-y-sweep atómico:

use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
use crate::state::{FactoryState, WalletReceiver};
use crate::errors::WalletFactoryError;
use crate::Recipient;

pub fn handler(
    ctx: Context<DeployAndSweepToken>,
    wallet_id: [u8; 32],
    recipients: Vec<Recipient>,
) -> Result<()> {
    let state = &ctx.accounts.factory_state;
    require!(!state.paused, WalletFactoryError::Paused);
    require!(ctx.accounts.relayer.key() == state.relayer, WalletFactoryError::NotRelayer);

    // Initialize wallet receiver
    let receiver = &mut ctx.accounts.wallet_receiver;
    receiver.wallet_id = wallet_id;
    receiver.factory = ctx.accounts.factory_state.key();
    receiver.initialized = true;
    receiver.bump = ctx.bumps.wallet_receiver;

    emit!(crate::instructions::deploy_wallet::WalletDeployed {
        wallet_id,
        receiver: ctx.accounts.wallet_receiver.key(),
    });

    let balance = ctx.accounts.source_token_account.amount;
    if balance > 0 {
        require!(!recipients.is_empty(), WalletFactoryError::InvalidRecipients);
        require!(recipients.len() <= 5, WalletFactoryError::TooManyRecipients);

        let mut total_bps: u64 = 0;
        for r in &recipients {
            require!(r.wallet != Pubkey::default(), WalletFactoryError::ZeroAddress);
            require!(r.bps <= 10_000, WalletFactoryError::BpsOverflow);
            total_bps += r.bps as u64;
        }
        require!(total_bps == 10_000, WalletFactoryError::BpsDoNotSum);

        let wallet_id_ref = wallet_id;
        let seeds = &[
            WalletReceiver::SEED,
            &wallet_id_ref,
            &[ctx.accounts.wallet_receiver.bump],
        ];
        let signer_seeds = &[&seeds[..]];

        let mut distributed: u64 = 0;
        let n = recipients.len();
        for (i, r) in recipients.iter().enumerate() {
            let amount = if i == n - 1 {
                balance - distributed
            } else {
                (balance as u128 * r.bps as u128 / 10_000) as u64
            };
            distributed += amount;

            if amount > 0 {
                let dest = &ctx.remaining_accounts[i];
                let cpi_accounts = Transfer {
                    from: ctx.accounts.source_token_account.to_account_info(),
                    to: dest.clone(),
                    authority: ctx.accounts.wallet_receiver.to_account_info(),
                };
                let cpi_ctx = CpiContext::new_with_signer(
                    ctx.accounts.token_program.to_account_info(),
                    cpi_accounts,
                    signer_seeds,
                );
                token::transfer(cpi_ctx, amount)?;
            }
        }

        emit!(crate::instructions::sweep_token::TokenSwept {
            wallet_id,
            receiver: ctx.accounts.wallet_receiver.key(),
            mint: ctx.accounts.source_token_account.mint,
            total_amount: balance,
            recipient_count: n as u8,
        });
    }

    Ok(())
}

#[derive(Accounts)]
#[instruction(wallet_id: [u8; 32])]
pub struct DeployAndSweepToken<'info> {
    #[account(
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    #[account(
        init,
        payer = relayer,
        space = WalletReceiver::LEN,
        seeds = [WalletReceiver::SEED, &wallet_id],
        bump,
    )]
    pub wallet_receiver: Account<'info, WalletReceiver>,

    #[account(
        mut,
        constraint = source_token_account.owner == wallet_receiver.key(),
    )]
    pub source_token_account: Account<'info, TokenAccount>,

    #[account(mut)]
    pub relayer: Signer<'info>,

    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>,

    // remaining_accounts: destination TokenAccounts (one per recipient)
}

Flujo de pre-fondeo de tokens: Para tokens SPL, el flujo requiere que alguien cree una Associated Token Account (ATA) para el PDA antes de enviar tokens. La dirección de la ATA también es determinista (derivada de la dirección del PDA + dirección del mint), por lo que tu backend puede calcularla y mostrarla al usuario. La instrucción deploy_and_sweep_token luego despliega el WalletReceiver y hace sweep del balance de tokens de una sola vez.

Instrucciones de Emergencia y Administración

Emergency Sweep SOL

El owner puede recuperar todo el SOL sweepable de cualquier wallet a un solo destino. Esto ignora la verificación de pausa y no requiere el relayer:

use anchor_lang::prelude::*;
use crate::state::{FactoryState, WalletReceiver};
use crate::errors::WalletFactoryError;

pub fn handler(ctx: Context<EmergencySweepSol>, wallet_id: [u8; 32]) -> Result<()> {
    let state = &ctx.accounts.factory_state;
    require!(ctx.accounts.owner.key() == state.owner, WalletFactoryError::NotOwner);

    let receiver_info = ctx.accounts.wallet_receiver.to_account_info();
    let rent = Rent::get()?;
    let min_rent = rent.minimum_balance(WalletReceiver::LEN);
    let balance = receiver_info
        .lamports()
        .checked_sub(min_rent)
        .ok_or(WalletFactoryError::NothingToSweep)?;

    require!(balance > 0, WalletFactoryError::NothingToSweep);

    let wallet_id_ref = wallet_id;
    let seeds = &[
        WalletReceiver::SEED,
        &wallet_id_ref,
        &[ctx.accounts.wallet_receiver.bump],
    ];
    let signer_seeds = &[&seeds[..]];

    let ix = anchor_lang::solana_program::system_instruction::transfer(
        &ctx.accounts.wallet_receiver.key(),
        &ctx.accounts.destination.key(),
        balance,
    );
    anchor_lang::solana_program::program::invoke_signed(
        &ix,
        &[
            ctx.accounts.wallet_receiver.to_account_info(),
            ctx.accounts.destination.to_account_info(),
            ctx.accounts.system_program.to_account_info(),
        ],
        signer_seeds,
    )?;

    emit!(EmergencySolSwept {
        wallet_id,
        receiver: ctx.accounts.wallet_receiver.key(),
        destination: ctx.accounts.destination.key(),
        amount: balance,
    });

    Ok(())
}

#[derive(Accounts)]
#[instruction(wallet_id: [u8; 32])]
pub struct EmergencySweepSol<'info> {
    #[account(
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    #[account(
        mut,
        seeds = [WalletReceiver::SEED, &wallet_id],
        bump = wallet_receiver.bump,
    )]
    pub wallet_receiver: Account<'info, WalletReceiver>,

    /// Destination to receive all SOL
    #[account(mut)]
    pub destination: SystemAccount<'info>,

    pub owner: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[event]
pub struct EmergencySolSwept {
    pub wallet_id: [u8; 32],
    pub receiver: Pubkey,
    pub destination: Pubkey,
    pub amount: u64,
}

Sin verificación de pausa: Los sweeps de emergencia intencionalmente omiten require!(!state.paused, ...). El escenario: detectas que la clave del relayer está comprometida, pausas la factory, luego usas la clave del owner para evacuar fondos. Si el sweep de emergencia respetara la pausa, tendrías que reanudar (re-exponiendo el vector de ataque del relayer) para recuperar fondos.

SystemAccount para destino: A diferencia de los sweeps regulares donde los destinatarios se pasan como Pubkey en la struct Recipient, el destino de emergencia es un SystemAccount validado. Esto es por conveniencia – el destino solo necesita ser una cuenta de sistema válida, no una cuenta propiedad de un programa.

Emergency Sweep Token

use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
use crate::state::{FactoryState, WalletReceiver};
use crate::errors::WalletFactoryError;

pub fn handler(ctx: Context<EmergencySweepToken>, wallet_id: [u8; 32]) -> Result<()> {
    let state = &ctx.accounts.factory_state;
    require!(ctx.accounts.owner.key() == state.owner, WalletFactoryError::NotOwner);

    let balance = ctx.accounts.source_token_account.amount;
    require!(balance > 0, WalletFactoryError::NothingToSweep);

    let wallet_id_ref = wallet_id;
    let seeds = &[
        WalletReceiver::SEED,
        &wallet_id_ref,
        &[ctx.accounts.wallet_receiver.bump],
    ];
    let signer_seeds = &[&seeds[..]];

    let cpi_accounts = Transfer {
        from: ctx.accounts.source_token_account.to_account_info(),
        to: ctx.accounts.destination_token_account.to_account_info(),
        authority: ctx.accounts.wallet_receiver.to_account_info(),
    };
    let cpi_ctx = CpiContext::new_with_signer(
        ctx.accounts.token_program.to_account_info(),
        cpi_accounts,
        signer_seeds,
    );
    token::transfer(cpi_ctx, balance)?;

    emit!(EmergencyTokenSwept {
        wallet_id,
        receiver: ctx.accounts.wallet_receiver.key(),
        mint: ctx.accounts.source_token_account.mint,
        destination: ctx.accounts.destination_token_account.key(),
        amount: balance,
    });

    Ok(())
}

#[derive(Accounts)]
#[instruction(wallet_id: [u8; 32])]
pub struct EmergencySweepToken<'info> {
    #[account(
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
    )]
    pub factory_state: Account<'info, FactoryState>,

    #[account(
        seeds = [WalletReceiver::SEED, &wallet_id],
        bump = wallet_receiver.bump,
    )]
    pub wallet_receiver: Account<'info, WalletReceiver>,

    #[account(
        mut,
        constraint = source_token_account.owner == wallet_receiver.key(),
    )]
    pub source_token_account: Account<'info, TokenAccount>,

    /// Destination token account (same mint, any owner)
    #[account(mut)]
    pub destination_token_account: Account<'info, TokenAccount>,

    pub owner: Signer<'info>,

    pub token_program: Program<'info, Token>,
}

#[event]
pub struct EmergencyTokenSwept {
    pub wallet_id: [u8; 32],
    pub receiver: Pubkey,
    pub mint: Pubkey,
    pub destination: Pubkey,
    pub amount: u64,
}

Set Relayer

Actualiza la clave pública del relayer. Solo el owner puede llamar esto:

use anchor_lang::prelude::*;
use crate::state::FactoryState;
use crate::errors::WalletFactoryError;

pub fn handler(ctx: Context<SetRelayer>, new_relayer: Pubkey) -> Result<()> {
    require!(new_relayer != Pubkey::default(), WalletFactoryError::ZeroAddress);

    let state = &mut ctx.accounts.factory_state;
    let old_relayer = state.relayer;
    state.relayer = new_relayer;

    emit!(RelayerUpdated {
        old_relayer,
        new_relayer,
    });

    Ok(())
}

#[derive(Accounts)]
pub struct SetRelayer<'info> {
    #[account(
        mut,
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
        has_one = owner,
    )]
    pub factory_state: Account<'info, FactoryState>,
    pub owner: Signer<'info>,
}

#[event]
pub struct RelayerUpdated {
    pub old_relayer: Pubkey,
    pub new_relayer: Pubkey,
}

Restricción has_one = owner: Esta restricción de Anchor verifica automáticamente que factory_state.owner == owner.key(). Es equivalente al manual require!(ctx.accounts.owner.key() == state.owner, ...) pero más idiomático. Usa has_one cuando el nombre del campo en la struct de cuenta coincide con el nombre de la cuenta en la struct Accounts.

Pause y Unpause

Operaciones simples de toggle protegidas por has_one = owner:

use anchor_lang::prelude::*;
use crate::state::FactoryState;

pub fn handler(ctx: Context<Pause>) -> Result<()> {
    let state = &mut ctx.accounts.factory_state;
    state.paused = true;
    Ok(())
}

#[derive(Accounts)]
pub struct Pause<'info> {
    #[account(
        mut,
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
        has_one = owner,
    )]
    pub factory_state: Account<'info, FactoryState>,
    pub owner: Signer<'info>,
}
use anchor_lang::prelude::*;
use crate::state::FactoryState;

pub fn handler(ctx: Context<Unpause>) -> Result<()> {
    let state = &mut ctx.accounts.factory_state;
    state.paused = false;
    Ok(())
}

#[derive(Accounts)]
pub struct Unpause<'info> {
    #[account(
        mut,
        seeds = [FactoryState::SEED],
        bump = factory_state.bump,
        has_one = owner,
    )]
    pub factory_state: Account<'info, FactoryState>,
    pub owner: Signer<'info>,
}

Estas son intencionalmente simples. En producción, podrías agregar:

  • Un delay de timelock para reanudar
  • Emisión de eventos para pausa/reanudación
  • Un contador que registre cuántas veces se ha pausado la factory (para monitoreo)

Conceptos Clave de Solana Explicados

Exención de Renta

Cada cuenta de Solana debe mantener un balance mínimo de SOL basado en su tamaño de datos. Esto previene spam en la red y asegura que los validadores sean compensados por almacenar estado.

Para nuestra cuenta WalletReceiver de 74 bytes, el mínimo exento de renta es aproximadamente 0.00114 SOL (1,141,440 lamports a las tasas actuales). Esto significa:

  • Si un usuario deposita 1 SOL en un PDA wallet receiver, el balance sweepable es ~0.99886 SOL
  • El mínimo exento de renta permanece en el PDA para siempre (a menos que se cierre la cuenta)
  • Tu backend debería tener esto en cuenta al mostrar balances disponibles

La fórmula es: sweepable = total_lamports - rent.minimum_balance(account_size)

Esta es una diferencia fundamental con EVM. En Ethereum, puedes hacer sweep del balance completo de ETH de un contrato. En Solana, debes dejar la renta.

Firma de PDA con Seeds

Un PDA no tiene clave privada. Entonces, ¿cómo “firma” transacciones? No lo hace, en el sentido tradicional. En su lugar:

  1. Tu programa construye una instrucción de transferencia especificando el PDA como origen
  2. Llama a invoke_signed con los seeds que derivan el PDA
  3. El runtime de Solana recalcula el PDA desde esos seeds + program ID
  4. Si el resultado coincide con la cuenta en la instrucción, la transferencia es autorizada
let seeds = &[
    WalletReceiver::SEED,            // b"wallet_receiver"
    &wallet_id_ref,                   // [u8; 32]
    &[ctx.accounts.wallet_receiver.bump],  // canonical bump
];
let signer_seeds = &[&seeds[..]];

// This invoke_signed lets the PDA "sign" the transfer
anchor_lang::solana_program::program::invoke_signed(
    &transfer_instruction,
    &[source_account, destination_account, system_program],
    signer_seeds,
)?;

Esto es criptográficamente seguro: solo el programa que derivó el PDA puede producir seeds de firma válidos para él. Ningún otro programa puede firmar por tu PDA.

Cross-Program Invocation (CPI) para Transferencias de Tokens

Las transferencias de SOL nativo usan el System Program. Las transferencias de tokens SPL usan el Token Program. Para transferir tokens desde un PDA, realizas una CPI:

let cpi_accounts = Transfer {
    from: source_token_account.to_account_info(),
    to: destination_token_account.to_account_info(),
    authority: wallet_receiver.to_account_info(),  // PDA is the authority
};
let cpi_ctx = CpiContext::new_with_signer(
    token_program.to_account_info(),
    cpi_accounts,
    signer_seeds,  // PDA seeds for signing
);
token::transfer(cpi_ctx, amount)?;

El CpiContext::new_with_signer de Anchor envuelve la llamada de bajo nivel invoke_signed en una interfaz tipada. El Token Program verifica que la authority (nuestro PDA) firmó la transacción usando los seeds proporcionados.

remaining_accounts para Listas Dinámicas de Destinatarios

La struct #[derive(Accounts)] de Anchor es estática – defines todas las cuentas en tiempo de compilación. Pero los sweeps de tokens necesitan un número variable de cuentas de token destino (1-5). La solución es remaining_accounts:

// In the Accounts struct:
// remaining_accounts: destination TokenAccounts (one per recipient)

// In the handler:
let dest_account = &ctx.remaining_accounts[i];

El cliente pasa estas cuentas extra al construir la transacción:

await program.methods
  .sweepToken([...walletId], recipients)
  .accounts({ /* ... static accounts ... */ })
  .remainingAccounts([
    { pubkey: destTokenAccount1, isSigner: false, isWritable: true },
    { pubkey: destTokenAccount2, isSigner: false, isWritable: true },
  ])
  .signers([relayer])
  .rpc();

Este patrón es común en programas de Solana que necesitan manejar listas de cuentas de longitud variable. La contrapartida es que remaining_accounts son referencias AccountInfo sin tipo – pierdes la deserialización automática y la verificación de restricciones de Anchor.

Tests de Integración

La suite de tests completa valida todos los flujos principales usando el framework de testing TypeScript de Anchor:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { WalletFactory } from "../target/types/wallet_factory";
import {
  PublicKey,
  Keypair,
  SystemProgram,
  LAMPORTS_PER_SOL,
} from "@solana/web3.js";
import {
  createMint,
  getOrCreateAssociatedTokenAccount,
  mintTo,
  TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import { assert } from "chai";

describe("wallet_factory", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);
  const program = anchor.workspace.WalletFactory as Program<WalletFactory>;

  const owner = provider.wallet;
  const relayer = Keypair.generate();
  const recipient1 = Keypair.generate();
  const recipient2 = Keypair.generate();

  // Derive factory state PDA
  const [factoryStatePDA] = PublicKey.findProgramAddressSync(
    [Buffer.from("factory_state")],
    program.programId
  );

  // Helper: derive wallet receiver PDA from wallet_id
  function walletReceiverPDA(walletId: Buffer): [PublicKey, number] {
    return PublicKey.findProgramAddressSync(
      [Buffer.from("wallet_receiver"), walletId],
      program.programId
    );
  }

  before(async () => {
    // Airdrop to relayer for fees
    await provider.connection.confirmTransaction(
      await provider.connection.requestAirdrop(relayer.publicKey, 2 * LAMPORTS_PER_SOL)
    );
  });

  // ─── Initialize ──────────────────────────────────────────────────────────────

  it("Initializes the factory", async () => {
    await program.methods
      .initialize(relayer.publicKey)
      .accounts({
        factoryState: factoryStatePDA,
        owner: owner.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .rpc();

    const state = await program.account.factoryState.fetch(factoryStatePDA);
    assert.equal(state.owner.toBase58(), owner.publicKey.toBase58());
    assert.equal(state.relayer.toBase58(), relayer.publicKey.toBase58());
    assert.equal(state.paused, false);
  });

  // ─── Deploy Wallet ────────────────────────────────────────────────────────────

  it("Deploys a wallet receiver", async () => {
    const walletId = Buffer.alloc(32);
    walletId.write("test-wallet-001");
    const [receiverPDA] = walletReceiverPDA(walletId);

    await program.methods
      .deployWallet([...walletId])
      .accounts({
        factoryState: factoryStatePDA,
        walletReceiver: receiverPDA,
        relayer: relayer.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .signers([relayer])
      .rpc();

    const receiver = await program.account.walletReceiver.fetch(receiverPDA);
    assert.equal(receiver.initialized, true);
    assert.deepEqual(receiver.walletId, [...walletId]);
  });

  // ─── Sweep SOL ────────────────────────────────────────────────────────────────

  it("Sweeps SOL from a wallet receiver (50/50 split)", async () => {
    const walletId = Buffer.alloc(32);
    walletId.write("test-wallet-sol");
    const [receiverPDA] = walletReceiverPDA(walletId);

    // Deploy wallet
    await program.methods
      .deployWallet([...walletId])
      .accounts({
        factoryState: factoryStatePDA,
        walletReceiver: receiverPDA,
        relayer: relayer.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .signers([relayer])
      .rpc();

    // Fund the PDA with SOL (direct transfer)
    const fundTx = new anchor.web3.Transaction().add(
      SystemProgram.transfer({
        fromPubkey: owner.publicKey,
        toPubkey: receiverPDA,
        lamports: LAMPORTS_PER_SOL,
      })
    );
    await provider.sendAndConfirm(fundTx);

    const r1Before = await provider.connection.getBalance(recipient1.publicKey);
    const r2Before = await provider.connection.getBalance(recipient2.publicKey);

    const recipients = [
      { wallet: recipient1.publicKey, bps: 5000 },
      { wallet: recipient2.publicKey, bps: 5000 },
    ];

    await program.methods
      .sweepSol([...walletId], recipients)
      .accounts({
        factoryState: factoryStatePDA,
        walletReceiver: receiverPDA,
        relayer: relayer.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .signers([relayer])
      .rpc();

    const r1After = await provider.connection.getBalance(recipient1.publicKey);
    const r2After = await provider.connection.getBalance(recipient2.publicKey);

    // Each should receive ~0.5 SOL
    assert.approximately(r1After - r1Before, 0.5 * LAMPORTS_PER_SOL, 1000);
    assert.approximately(r2After - r2Before, 0.5 * LAMPORTS_PER_SOL, 1000);
  });

  // ─── Deploy + Sweep SOL Atomically ───────────────────────────────────────────

  it("Deploys and sweeps SOL atomically", async () => {
    const walletId = Buffer.alloc(32);
    walletId.write("test-atomic-sol");
    const [receiverPDA] = walletReceiverPDA(walletId);

    // Pre-fund the PDA address (it doesn't exist yet as a program account,
    // but we can send lamports to it — they'll be there when init runs)
    const fundTx = new anchor.web3.Transaction().add(
      SystemProgram.transfer({
        fromPubkey: owner.publicKey,
        toPubkey: receiverPDA,
        lamports: LAMPORTS_PER_SOL,
      })
    );
    await provider.sendAndConfirm(fundTx);

    const recipients = [
      { wallet: recipient1.publicKey, bps: 7000 },
      { wallet: recipient2.publicKey, bps: 3000 },
    ];

    await program.methods
      .deployAndSweepSol([...walletId], recipients)
      .accounts({
        factoryState: factoryStatePDA,
        walletReceiver: receiverPDA,
        relayer: relayer.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .signers([relayer])
      .rpc();

    const receiver = await program.account.walletReceiver.fetch(receiverPDA);
    assert.equal(receiver.initialized, true);
  });

  // ─── Emergency Sweep SOL ─────────────────────────────────────────────────────

  it("Emergency sweeps SOL (owner only)", async () => {
    const walletId = Buffer.alloc(32);
    walletId.write("test-emergency");
    const [receiverPDA] = walletReceiverPDA(walletId);

    await program.methods
      .deployWallet([...walletId])
      .accounts({
        factoryState: factoryStatePDA,
        walletReceiver: receiverPDA,
        relayer: relayer.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .signers([relayer])
      .rpc();

    const fundTx = new anchor.web3.Transaction().add(
      SystemProgram.transfer({
        fromPubkey: owner.publicKey,
        toPubkey: receiverPDA,
        lamports: LAMPORTS_PER_SOL,
      })
    );
    await provider.sendAndConfirm(fundTx);

    const emergency = Keypair.generate();

    await program.methods
      .emergencySweepSol([...walletId])
      .accounts({
        factoryState: factoryStatePDA,
        walletReceiver: receiverPDA,
        destination: emergency.publicKey,
        owner: owner.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .rpc();

    const destBalance = await provider.connection.getBalance(emergency.publicKey);
    assert.isAbove(destBalance, 0);
  });

  // ─── Pause / Unpause ─────────────────────────────────────────────────────────

  it("Pauses and unpauses the factory", async () => {
    await program.methods
      .pause()
      .accounts({ factoryState: factoryStatePDA, owner: owner.publicKey })
      .rpc();

    let state = await program.account.factoryState.fetch(factoryStatePDA);
    assert.equal(state.paused, true);

    await program.methods
      .unpause()
      .accounts({ factoryState: factoryStatePDA, owner: owner.publicKey })
      .rpc();

    state = await program.account.factoryState.fetch(factoryStatePDA);
    assert.equal(state.paused, false);
  });
});

Explicación de los Tests

Setup: El test crea un provider (conexión + wallet), la interfaz del programa y keypairs para el relayer y dos destinatarios. El hook before hace airdrop de 2 SOL al relayer para que pueda pagar fees de transacción y renta de cuentas.

Derivación de PDA: El helper walletReceiverPDA usa PublicKey.findProgramAddressSync para calcular la misma dirección que el programa on-chain derivará. Esta es la clave del direccionamiento determinista – tu código off-chain y tu programa on-chain coinciden en la dirección antes de que exista.

wallet_id como Buffer: El wallet_id de 32 bytes se crea como un Buffer relleno de ceros con un prefijo legible escrito. En producción, usarías un UUID o hash. El operador spread [...walletId] lo convierte a un number[] que el serializador de Anchor espera.

Fondeo antes del deploy: El test de sweep atómico demuestra el pre-fondeo: se envía SOL a la dirección del PDA antes de que la cuenta WalletReceiver sea creada. Cuando deploy_and_sweep_sol se ejecuta, la restricción init crea la cuenta (heredando los lamports pre-fondeados), y el handler hace sweep de inmediato.

Aserciones de balance: assert.approximately permite una tolerancia de 1000 lamports (~0.000001 SOL) para considerar el posible redondeo en la distribución por BPS.

Ejecutar los Tests

# Start local validator
solana-test-validator

# In another terminal
anchor build
anchor deploy
anchor test

O con un solo comando que maneja todo:

anchor test

Generación de Direcciones de Wallet Off-Chain

Tu backend necesita calcular direcciones de depósito sin interactuar con la blockchain. Así es como se deriva cualquier dirección de wallet receiver:

import { PublicKey } from "@solana/web3.js";

const PROGRAM_ID = new PublicKey("Bv3xMqy2kyu7RX3Vzi9PVgo12UoWcLLhWN71kcmTQRFF");

/**
 * Derive the wallet receiver PDA for a given wallet ID.
 * Returns the same address the on-chain program will use.
 */
function deriveWalletAddress(walletId: string | Buffer): PublicKey {
  const walletIdBuffer = typeof walletId === "string"
    ? Buffer.alloc(32, 0).fill(walletId, 0, Math.min(walletId.length, 32))
    : walletId;

  const [pda] = PublicKey.findProgramAddressSync(
    [Buffer.from("wallet_receiver"), walletIdBuffer],
    PROGRAM_ID
  );

  return pda;
}

// Example: generate a deposit address for user "user-47291"
const walletId = Buffer.alloc(32);
walletId.write("user-47291");

const depositAddress = deriveWalletAddress(walletId);
console.log(`Deposit address: ${depositAddress.toBase58()}`);
// Output: Deposit address: 7xKX...deterministic...address

Para Depósitos de Tokens SPL

Para recibir tokens SPL, el usuario necesita la dirección de la Associated Token Account (ATA) del PDA:

import { getAssociatedTokenAddressSync } from "@solana/spl-token";

const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");

const walletPDA = deriveWalletAddress(walletId);
const tokenDepositAddress = getAssociatedTokenAddressSync(
  USDC_MINT,
  walletPDA,
  true  // allowOwnerOffCurve = true (required for PDAs)
);

console.log(`USDC deposit address: ${tokenDepositAddress.toBase58()}`);

El parámetro allowOwnerOffCurve = true es esencial porque el owner del PDA está fuera de la curva ed25519. Sin él, la función lanza un error.

Generación de Direcciones en Lote

Para generar miles de direcciones de depósito:

function generateDepositAddresses(
  userIds: string[],
  mint?: PublicKey
): Map<string, { sol: PublicKey; token?: PublicKey }> {
  const result = new Map();

  for (const userId of userIds) {
    const walletId = Buffer.alloc(32);
    walletId.write(userId);

    const solAddress = deriveWalletAddress(walletId);
    const entry: { sol: PublicKey; token?: PublicKey } = { sol: solAddress };

    if (mint) {
      entry.token = getAssociatedTokenAddressSync(mint, solAddress, true);
    }

    result.set(userId, entry);
  }

  return result;
}

// Generate 10,000 deposit addresses in milliseconds
const userIds = Array.from({ length: 10000 }, (_, i) => `user-${i}`);
const addresses = generateDepositAddresses(userIds, USDC_MINT);

findProgramAddressSync es un cálculo de hash puro – sin llamadas RPC. Generar 10,000 direcciones toma menos de un segundo en hardware moderno.

Consideraciones de Seguridad

Control de Acceso

El programa aplica control de acceso de dos niveles:

OperaciónFirmante Requerido¿Verifica Pausa?
initializeOwner (implícito, solo primera llamada)No
deploy_walletRelayer
sweep_solRelayer
sweep_tokenRelayer
deploy_and_sweep_solRelayer
deploy_and_sweep_tokenRelayer
emergency_sweep_solOwnerNo
emergency_sweep_tokenOwnerNo
set_relayerOwnerNo
pauseOwnerNo
unpauseOwnerNo

Si la clave del relayer se ve comprometida:

  1. Llamar pause() – bloquea inmediatamente todas las operaciones del relayer
  2. Llamar set_relayer(new_key) – rotar a una clave nueva
  3. Usar emergency_sweep_sol / emergency_sweep_token para recuperar fondos en riesgo
  4. Llamar unpause() – reanudar operaciones con el nuevo relayer

Garantías de Exención de Renta

El programa nunca permite hacer sweep por debajo del mínimo exento de renta. Esto se aplica en dos niveles:

  1. En sweep_sol: checked_sub(min_rent) retorna un error si los lamports están por debajo de la renta
  2. En deploy_and_sweep_sol: saturating_sub(min_rent) retorna 0 (salta el sweep) si está en el mínimo

Sin esto, el runtime de Solana haría garbage collection de la cuenta, perdiendo permanentemente el PDA y cualquier depósito futuro a esa dirección.

Validación de PDA

Cada instrucción valida los PDAs a través de las restricciones de seeds de Anchor:

#[account(
    seeds = [WalletReceiver::SEED, &wallet_id],
    bump = wallet_receiver.bump,
)]
pub wallet_receiver: Account<'info, WalletReceiver>,

Esto asegura:

  • La cuenta wallet_receiver fue creada por este programa (verificación de discriminador)
  • La dirección de la cuenta coincide con el PDA esperado para el wallet_id dado (verificación de seeds)
  • El bump almacenado coincide con el bump canónico (verificación de bump)

Un atacante no puede sustituir una cuenta diferente o un PDA de otro programa.

Validación de BPS

Cada instrucción de sweep valida que:

  • Se proporcione al menos 1 destinatario
  • No más de 5 destinatarios (limita el uso de cómputo y el tamaño de transacción)
  • Ningún destinatario tenga Pubkey::default() (la dirección cero)
  • Ningún BPS individual exceda 10,000
  • Los BPS totales sumen exactamente 10,000

El límite de 5 destinatarios es una restricción práctica. Cada destinatario requiere una llamada adicional a invoke_signed (para SOL) o CPI (para tokens). Las transacciones de Solana tienen un presupuesto de cómputo de 200,000 unidades de cómputo por defecto – 5 destinatarios se mantiene holgadamente dentro de este límite.

Comparación con el Modelo de Seguridad EVM

PreocupaciónWallet Factory EVMWallet Factory Solana
ReentranciaRiesgo mayor; usar ReentrancyGuardNo aplica (sin callbacks en system_program::transfer)
Front-runningBots de MEV pueden hacer front-run a deploysLos validadores pueden reordenar pero los PDAs no son sensibles a race conditions
Riesgo de upgrade de proxyEl patrón proxy introduce vectores de upgradeSin proxies; los upgrades del programa son separados de los datos
Ataques con selfdestructCREATE2 + selfdestruct para re-desplegarNo es posible; las cuentas PDA persisten
Exploits de aprobación de tokensPatrón approve/transferFromEl PDA es la autoridad; no se necesita aprobación
Gas griefingEl contrato destinatario puede consumir gasLas cuentas destinatarias son pasivas; sin callback

El modelo de cuentas de Solana elimina varios vectores de ataque que afectan a las wallet factories de EVM:

  • Sin reentrancia: las transferencias del sistema y transferencias de tokens SPL no ejecutan código arbitrario en el destinatario
  • Sin cadenas de aprobación: el PDA posee directamente la cuenta de token y es la autoridad de transferencia
  • Sin re-despliegue por self-destruct: una vez que una cuenta PDA existe, no puede ser destruida y recreada con estado diferente

Conclusión

Qué Construimos

Una wallet factory determinista completa en Solana que:

  • Crea direcciones de depósito únicas a partir de un identificador de 32 bytes usando PDAs
  • Distribuye SOL recibido a múltiples destinatarios basándose en puntos base
  • Distribuye tokens SPL recibidos usando CPI al Token Program
  • Soporta deploy-y-sweep atómico tanto para SOL como para tokens SPL
  • Incluye recuperación de emergencia que ignora el mecanismo de pausa
  • Aplica control de acceso de dos niveles (owner / relayer)
  • Proporciona tests de integración en TypeScript y derivación de direcciones off-chain

Diferencias Clave con EVM

  1. Los PDAs reemplazan a CREATE2: Mismo determinismo, sin despliegue de bytecode, menor costo
  2. Exención de renta: No puedes hacer sweep del 100% del SOL – el mínimo de renta queda bloqueado
  3. Sin riesgo de reentrancia: Las transferencias del sistema y CPI de tokens no hacen callback a tu programa
  4. Modelo de cuentas: El estado está en cuentas, no en slots de storage de contratos
  5. Paso explícito de cuentas: Cada cuenta debe ser declarada en la transacción
  6. remaining_accounts: Las listas dinámicas de destinatarios usan arrays de cuentas sin tipo

Próximos Pasos

  • Agregar soporte para Token-2022: El estándar de token más nuevo soporta fees de transferencia, transferencias confidenciales y más
  • Implementar cierre de cuentas: Cerrar cuentas WalletReceiver para recuperar renta cuando ya no se necesiten
  • Agregar verificación por lotes basada en Merkle: Procesar cientos de sweeps en una sola transacción usando pruebas comprimidas
  • Desplegar en devnet/mainnet: Ejecutar anchor deploy --provider.cluster 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.

Aviso: El código presentado en este tutorial tiene fines educativos. Antes de desplegarlo en una red principal (mainnet), se recomienda realizar una auditoría de seguridad profesional. Beltsys Labs no se responsabiliza del uso que se haga de este código.

Solana Anchor Rust PDA SPL Token

¿Necesitas ayuda con tu proyecto?

Nuestro equipo de expertos puede implementar estas soluciones por ti.

Contacte con nosotros