En tutoriales anteriores, teníamos que inicializar una cuenta en una transacción separada antes de poder escribir datos en ella. Es posible que deseemos poder inicializar una cuenta y escribir datos en ella en una sola transacción para simplificar las cosas para el usuario.
Anchor proporciona una práctica macro llamada init_if_needed que, como su nombre indica, inicializará la cuenta si esta no existe.
El ejemplo de contador a continuación no necesita una transacción initialize separada; comenzará a sumar “1” al almacenamiento de counter de inmediato.
Rust:
use anchor_lang::prelude::*;
use std::mem::size_of;
declare_id!("9DbiqCqtqgP3NYufxBakbeRd7JyNpNYbsm6Jqrn8Z2Hn");
#[program]
pub mod init_if_needed {
use super::*;
pub fn increment(ctx: Context<Initialize>) -> Result<()> {
let current_counter = ctx.accounts.my_pda.counter;
ctx.accounts.my_pda.counter = current_counter + 1;
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init_if_needed,
payer = signer,
space = size_of::<MyPDA>() + 8,
seeds = [],
bump
)]
pub my_pda: Account<'info, MyPDA>,
#[account(mut)]
pub signer: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct MyPDA {
pub counter: u64,
}
Typescript:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { InitIfNeeded } from "../target/types/init_if_needed";
describe("init_if_needed", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.InitIfNeeded as Program<InitIfNeeded>;
it("Is initialized!", async () => {
const [myPda, _bump] = anchor.web3.PublicKey.findProgramAddressSync([], program.programId);
await program.methods.increment().accounts({myPda: myPda}).rpc();
await program.methods.increment().accounts({myPda: myPda}).rpc();
await program.methods.increment().accounts({myPda: myPda}).rpc();
let result = await program.account.myPda.fetch(myPda);
console.log(`counter is ${result.counter}`);
});
});
Cuando intentamos compilar este programa con anchor build, obtendremos el siguiente error:

Para hacer que desaparezca el error init_if_needed requires that anchor-lang be imported with the init-if-needed cargo feature enabled, podemos abrir el archivo Cargo.toml en programs/<anchor_project_name> y agregar la siguiente línea:
[dependencies]
anchor-lang = { version = "0.29.0", features = ["init-if-needed"] }
Pero antes de simplemente silenciar el error, debemos entender qué es un ataque de reinicialización y cómo puede ocurrir.
En los programas de Anchor, las cuentas no se pueden inicializar dos veces (por defecto)
Si intentamos inicializar una cuenta que ya ha sido inicializada, la transacción fallará.
¿Cómo sabe Anchor que una cuenta ya está inicializada?
Desde la perspectiva de Anchor, si la cuenta tiene un saldo de cero lamports O la cuenta es propiedad del system_program, entonces no está inicializada.
Una cuenta que sea propiedad del system_program o que tenga un saldo de cero lamports puede ser inicializada de nuevo.
Para ilustrar esto, tenemos un programa de Solana con la típica función initialize (que usa init, no init_if_needed). También tiene una función drain_lamports y una función give_to_system_program, y ambas hacen lo que sus nombres sugieren:
use anchor_lang::prelude::*;
use std::mem::size_of;
use anchor_lang::system_program;
declare_id!("FC467mPCCKXG97ut1WdLLi55vuAcyCW8AD1vid27bZfn");
#[program]
pub mod reinit_attack {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
Ok(())
}
pub fn drain_lamports(ctx: Context<DrainLamports>) -> Result<()> {
let lamports = ctx.accounts.my_pda.to_account_info().lamports();
ctx.accounts.my_pda.sub_lamports(lamports)?;
ctx.accounts.signer.add_lamports(lamports)?;
Ok(())
}
pub fn give_to_system_program(ctx: Context<GiveToSystemProgram>) -> Result<()> {
let account_info = &mut ctx.accounts.my_pda.to_account_info();
// the assign method changes the owner
account_info.assign(&system_program::ID);
account_info.realloc(0, false)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct DrainLamports<'info> {
#[account(mut)]
pub my_pda: Account<'info, MyPDA>,
#[account(mut)]
pub signer: Signer<'info>,
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = signer, space = 8, seeds = [], bump)]
pub my_pda: Account<'info, MyPDA>,
#[account(mut)]
pub signer: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct GiveToSystemProgram<'info> {
#[account(mut)]
pub my_pda: Account<'info, MyPDA>,
}
#[account]
pub struct MyPDA {}
Ahora considera la siguiente prueba unitaria:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ReinitAttack } from "../target/types/reinit_attack";
describe("Program", () => {
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.ReinitAttack as Program<ReinitAttack>;
it("initialize after giving to system program or draining lamports", async () => {
const [myPda, _bump] = anchor.web3.PublicKey.findProgramAddressSync([], program.programId);
await program.methods.initialize().accounts({myPda: myPda}).rpc();
await program.methods.giveToSystemProgram().accounts({myPda: myPda}).rpc();
await program.methods.initialize().accounts({myPda: myPda}).rpc();
console.log("account initialized after giving to system program!")
await program.methods.drainLamports().accounts({myPda: myPda}).rpc();
await program.methods.initialize().accounts({myPda: myPda}).rpc();
console.log("account initialized after draining lamports!")
});
});
La secuencia es la siguiente:
- Inicializamos el PDA
- Transferimos la propiedad del PDA al
system_program - Llamamos a
initializede nuevo, y tiene éxito - Vaciamos los lamports de la cuenta
my_pda - Con un saldo de cero lamports, el entorno de ejecución de Solana considera que la cuenta es inexistente, ya que será programada para su eliminación al dejar de estar exenta de renta (rent exempt).
- Llamamos a
initializede nuevo, y tiene éxito. Hemos reinicializado con éxito la cuenta después de seguir esta secuencia.
De nuevo, Solana no tiene una bandera o indicador de “inicializado” ni nada por el estilo. Anchor permitirá que una transacción initialize tenga éxito si el propietario es el system_program o el saldo de lamports es cero.
Por qué la reinicialización podría ser un problema en nuestro ejemplo
Transferir la propiedad al system_program requiere borrar los datos en la cuenta. Eliminar todos los lamports “comunica” que no deseas que la cuenta siga existiendo.
¿Tu intención al realizar cualquiera de estas acciones es reiniciar el contador o poner fin a la vida del contador? Si tu aplicación nunca espera que el contador se reinicie, esto podría provocar errores (bugs).
Anchor quiere que pienses detenidamente en tu intención con esto, que es la razón por la que te hace pasar por el aro adicional de habilitar una bandera de característica (feature flag) en Cargo.toml.
Si te parece bien que el contador se reinicie en algún momento y vuelva a contar hacia arriba, la reinicialización no es un problema. Pero si el contador nunca debería reiniciarse a cero bajo ninguna circunstancia, entonces probablemente sería mejor que implementaras la función initialization de forma separada y agregaras una medida de seguridad para asegurarte de que solo se pueda llamar una vez en su vida útil (por ejemplo, almacenando una bandera booleana en una cuenta separada).
Por supuesto, es posible que tu programa no tenga necesariamente el mecanismo para transferir la cuenta al system_program o retirar lamports de la cuenta. Pero Anchor no tiene forma de saber esto, por lo que siempre lanza la advertencia sobre init_if_needed porque no puede determinar si la cuenta puede volver a un estado inicializable.
Tener dos rutas de inicialización podría provocar un error de por uno (off-by-one error) u otros comportamientos sorprendentes
En nuestro ejemplo de contador con init_if_needed, el contador nunca es igual a cero porque la primera transacción de inicialización también incrementa el valor de cero a uno.
Si también tuviéramos una función de inicialización normal que no incrementara el contador, entonces el contador se inicializaría y tendría un valor de cero. Si cierta lógica de negocio nunca espera ver un contador con un valor de cero, entonces podría ocurrir un comportamiento inesperado.
En Ethereum, los valores de almacenamiento para variables que nunca han sido “tocadas” tienen un valor predeterminado de cero. En Solana, las cuentas que no han sido inicializadas no contienen variables con valor cero — no existen y no pueden ser leídas.
“Initialization” no siempre significa “init” en Anchor
De forma algo confusa, algunos utilizan el término “inicializar” para referirse a “escribir datos en la cuenta por primera vez” en un sentido más general que la macro init de Anchor.
Si observamos el programa de ejemplo de Soldev, vemos que no se utiliza la macro init:
use anchor_lang::prelude::*;
use borsh::{BorshDeserialize, BorshSerialize};
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod initialization_insecure {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let mut user = User::try_from_slice(&ctx.accounts.user.data.borrow()).unwrap();
user.authority = ctx.accounts.authority.key();
user.serialize(&mut *ctx.accounts.user.data.borrow_mut())?;
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
user: AccountInfo<'info>,
#[account(mut)]
authority: Signer<'info>,
}
#[derive(BorshSerialize, BorshDeserialize)]
pub struct User {
authority: Pubkey,
}
El código está leyendo directamente la cuenta en la línea 11 y luego configurando los campos. El programa sobrescribe ciegamente los datos, ya sea que esté escribiendo por primera vez o por segunda vez (o tercera vez).
En cambio, la nomenclatura para “inicializar” aquí es “escribir en la cuenta por primera vez”.
El “ataque de reinicialización” aquí es de una variedad diferente a la que advierte el framework de Anchor. Específicamente, se puede llamar a initialize varias veces. La macro init de Anchor comprueba que el saldo de lamports no sea cero y que el programa ya sea propietario de la cuenta, lo que evitaría múltiples llamadas a initialize. La macro init puede ver si la cuenta ya tiene lamports o es propiedad del programa. Sin embargo, el código anterior no tiene tales comprobaciones.
Vale la pena revisar su tutorial para ver esta variedad de ataque de reinicialización.
Ten en cuenta que esto utiliza una versión más antigua de Anchor. AccountInfo es otro término para UncheckedAccount, por lo que deberás agregar un comentario /// Check: encima.
Borrar el discriminador de la cuenta (account discriminator) no hará que la cuenta sea reinicializable
El hecho de que una cuenta esté inicializada o no, no tiene nada que ver con los datos (o la falta de ellos) en su interior.
Para borrar los datos de una cuenta sin transferirla:
use anchor_lang::prelude::*;
use std::mem::size_of;
use anchor_lang::system_program;
declare_id!("FC467mPCCKXG97ut1WdLLi55vuAcyCW8AD1vid27bZfn");
#[program]
pub mod reinit_attack {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
Ok(())
}
pub fn erase(ctx: Context<Erase>) -> Result<()> {
ctx.accounts.my_pda.realloc(0, false)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct Erase<'info> {
/// CHECK: We are going to erase the account
#[account(mut)]
pub my_pda: UncheckedAccount<'info>,
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = signer, space = 8, seeds = [], bump)]
pub my_pda: Account<'info, MyPDA>,
#[account(mut)]
pub signer: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct MyPDA {}
Es importante que borremos los datos utilizando un UncheckedAccount, ya que .realloc(0, false) no es un método disponible en un Account normal.
Esta operación borrará el discriminador de la cuenta, por lo que ya no será legible a través de Account.
Ejercicio: inicializa la cuenta, llama a erase y luego intenta inicializar la cuenta de nuevo. Fallará porque, aunque la cuenta no tenga datos, sigue siendo propiedad del programa y tiene un saldo de lamports distinto de cero.
Resumen
La macro init_if_needed puede ser conveniente para evitar la necesidad de dos transacciones para interactuar con una nueva cuenta de almacenamiento. El framework de Anchor la bloquea por defecto para obligarnos a considerar las siguientes posibles situaciones indeseables:
- Si existe un método para reducir el saldo de lamports a cero o transferir la propiedad al
system_program, entonces la cuenta puede ser reinicializada. Esto puede ser o no un problema dependiendo de los requisitos del negocio. - Si el programa tiene tanto una macro
initcomo una macroinit_if_needed, el desarrollador debe asegurarse de que tener dos rutas de código no resulte en un estado inesperado. - Incluso después de que los datos de una cuenta se borren por completo, la cuenta sigue estando inicializada.
- Si el programa tiene una función que escribe “ciegamente” en una cuenta, entonces los datos en esa cuenta podrían ser sobrescritos. Esto generalmente requiere cargar la cuenta a través de
AccountInfoo su aliasUncheckedAccount.
Aprende más con RareSkills
Consulta nuestro curso de desarrollo de Solana para ver el resto de nuestros tutoriales de Solana. ¡Gracias por leer!
Publicado originalmente el 8 de marzo de 2024