En este tutorial de dos partes, aprenderemos cómo crear cuentas para almacenar datos en programas nativos de Solana utilizando dos enfoques: keypairs (esta parte) y Program Derived Addresses o PDAs (Parte 2). Nuestro objetivo es entender la asignación de cuentas, la inicialización y la serialización de datos a bajo nivel — la lógica que la macro #[account(init)] de Anchor abstrae por ti.
Para ambos enfoques, construiremos un programa que crea una cuenta y escribe datos en ella, y luego lo probaremos con un cliente en TypeScript para verificar que los datos se escribieron correctamente.
Configurando el programa de almacenamiento con keypair
Ejecuta los siguientes comandos para crear un directorio e inicializar un proyecto de Rust con Cargo:
mkdir solana-storage-write
cd solana-storage-write
cargo init --lib
Actualiza tu Cargo.toml con la configuración a continuación, la cual establece el tipo de crate y agrega las dependencias del programa de Solana:
[package]
name = "solana-storage-write"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
[dependencies]
solana-program = "1.18"
borsh = "0.10"
Ten en cuenta que hemos agregado borsh = "0.10" para la serialización, lo cual necesitaremos al crear nuestra cuenta. Cubrimos la serialización con Borsh en detalle en un tutorial anterior.
Ahora vamos a crear nuestro programa.
Creando cuentas con keypairs en programas nativos de Solana
Estructura de la cuenta para el almacenamiento de datos
Antes de sumergirnos en el código, entendamos cómo se ve una cuenta de Solana que almacena datos:

El campo data es donde almacenamos nuestro struct serializado (veremos esto más adelante). Cuando creamos una cuenta, especificamos cuántos bytes debe contener este campo, y el System Program asigna ese espacio.
Pasos para crear una cuenta de almacenamiento basada en keypair
Los pasos a continuación muestran cómo crear una cuenta basada en keypair para el almacenamiento de datos:
- Recibir la public key de la cuenta de almacenamiento desde el cliente (quien generó el keypair y firmará la transacción).
- Crear la estructura de datos que queremos almacenar (un struct
CounterDatacon un campocountde tipou64) y serializarla con Borsh. - Determinar el espacio necesario (la longitud en bytes de los datos serializados que establece el tamaño del campo data de la cuenta).
- Calcular los lamports rent-exempt requeridos para ese espacio (el balance mínimo de SOL requerido para mantener la cuenta activa sin ser eliminada por el recolector de basura).
- Usar el System Program para crear la cuenta con el espacio y los lamports calculados.
- Escribir los datos serializados directamente en el campo data de la cuenta.
Ahora veamos esto en código. Reemplaza el código en el archivo src/lib.rs con lo siguiente. Hemos agregado comentarios en el código para mostrar dónde implementamos cada uno de los pasos enumerados anteriormente:
use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::{
account_info::{next_account_info, AccountInfo},
entrypoint,
entrypoint::ProgramResult,
msg,
program::invoke,
program_error::ProgramError,
pubkey::Pubkey,
rent::Rent,
system_instruction, system_program,
};
entrypoint!(process_instruction);
// This represents the data we'll store in our account
// We've added Borsh derive macros for serialization and deserialization
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct CounterData {
pub count: u64,
}
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
_instruction_data: &[u8],
) -> ProgramResult {
msg!("Storage Write Program: Creating storage account and writing data");
let accounts_iter = &mut accounts.iter();
// STEP 1: Get the accounts we need
// next_account_info() extracts the next AccountInfo from the iterator
let storage_account = next_account_info(accounts_iter)?;
let signer = next_account_info(accounts_iter)?;
let system_program = next_account_info(accounts_iter)?;
// Verify the system program
if system_program.key != &system_program::ID {
msg!("Invalid system program");
return Err(ProgramError::IncorrectProgramId);
}
// Verify the signer account is actually a signer
if !signer.is_signer {
msg!("Signer must be a signer");
return Err(ProgramError::MissingRequiredSignature);
}
// STEP 2: Create our counter data
let counter_data = CounterData { count: 42 };
// STEP 2: Serialize the data with Borsh (u64 becomes 8 bytes in little-endian format)
let serialized_data = counter_data.try_to_vec()?; // [42, 0, 0, 0, 0, 0, 0, 0]
// STEP 3: Determine the space needed for our data
let space = serialized_data.len();
msg!("Creating storage account with {} bytes", space);
msg!("Serialized data: {:?}", serialized_data);
// STEP 4: Calculate lamports needed for rent exemption
let lamports = Rent::default().minimum_balance(space);
// STEP 5: Create the account using system program
let create_account_ix = system_instruction::create_account(
signer.key,
storage_account.key,
lamports,
space as u64,
program_id,
);
// STEP 5 (continued): Execute the create_account instruction
invoke(
&create_account_ix,
&[
signer.clone(),
storage_account.clone(),
system_program.clone(),
],
)?;
msg!("Storage account created successfully");
// STEP 6: Write our serialized data to the account
let mut account_data = storage_account.try_borrow_mut_data()?;
account_data.copy_from_slice(&serialized_data);
msg!("Data written to storage account");
Ok(())
}
Ahora veremos los pasos detrás de la creación de la cuenta.
Cómo funciona la creación de cuentas con keypair en nuestro programa en Rust
La creación de la cuenta en nuestro programa ocurre en dos pasos. Primero, construimos la instrucción con system_instruction::create_account() — una función auxiliar que construye el struct Instruction con las cuentas y datos correctos para la creación de la cuenta (en el tutorial de CPI, construimos este struct manualmente).

Segundo, ejecutamos esta instrucción usando invoke(), que realiza una Cross-Program Invocation (CPI) al System Program. El System Program entonces crea realmente la cuenta on-chain con los parámetros que especificamos.
La instrucción create_account
La instrucción create_account del System Program crea una nueva cuenta on-chain transfiriendo lamports, asignando espacio para datos y asignando un propietario (owner). Toma cinco parámetros:
- Payer: La cuenta que financia la renta (rent) de la nueva cuenta.
- New account address: La dirección en la que se creará la cuenta (la cuenta que se está inicializando).
- Lamports: La cantidad de SOL a transferir a la nueva cuenta (debe cubrir el rent-exemption).
- Space: El número de bytes a asignar para los datos de la cuenta.
- Owner: El program ID (public key) que será propietario de la nueva cuenta.
Dado que el System Program requiere que la dirección de la nueva cuenta sea un signer en la instrucción create_account, una cuenta de tipo keypair funciona como se espera: la transacción incluye una firma de la clave privada (private key) del keypair. Un PDA no tiene clave privada, por lo que el programa que lo crea debe suministrar las semillas (seeds) a través de invoke_signed(), las cuales el runtime utiliza para re-derivar y verificar el PDA, otorgándole autoridad de firma (veremos esto en la Parte 2).
¿Cómo sabe Solana que la dirección del keypair está disponible para la creación de la cuenta?
El cliente genera un nuevo keypair y pasa su public key en la transacción. Nuestro programa recibe esta dirección a través de let storage_account = next_account_info(accounts_iter)?;. Cuando el System Program procesa la instrucción create_account, verifica si ya existe una cuenta en esa dirección. Si no existe ninguna cuenta allí, el System Program la crea. Si ya existe una cuenta, la instrucción falla con un error.
Cómo se almacenan los datos en una cuenta de Solana
Ahora que hemos visto el código, repasemos exactamente qué sucede cuando almacenamos datos en nuestra cuenta de Solana.
Primero, comenzamos con nuestro struct:

Esto es solo un struct normal de Rust que reside en memoria. Pero las cuentas de Solana no pueden almacenar structs de Rust directamente, solo entienden bytes crudos. Así que necesitamos convertir nuestro struct en bytes usando Borsh:

Este método try_to_vec() está disponible porque agregamos previamente el atributo #[derive(BorshSerialize, BorshDeserialize)] a nuestro struct CounterData.

Esas macros derive generan el código para convertir nuestro struct hacia y desde bytes. Borsh toma nuestro count: 42 (un u64) y lo convierte en 8 bytes en formato little-endian. El valor 42 se convierte en [42, 0, 0, 0, 0, 0, 0, 0], el primer byte es 42 y el resto son ceros porque u64 siempre ocupa exactamente 8 bytes (tal como discutimos en el tutorial de serialización con Borsh). Esto es necesario porque las cuentas de Solana solo pueden almacenar bytes crudos, no structs de Rust directamente.
A continuación, creamos la cuenta con la instrucción create_account del System Program.

La instrucción create_account toma estos parámetros:
signer.key: La dirección de la cuenta que paga por la creación de la nueva cuenta.storage_account.key: La dirección donde se creará la nueva cuenta.lamports: Cantidad de SOL para financiar la nueva cuenta (para rent exemption).space as u64: Tamaño del campo data de la nueva cuenta en bytes.program_id: Qué programa será propietario de la cuenta recién creada (nuestro programa en este caso).
Luego creamos la cuenta con invoke (importado del crate solana_program):

Después de crear esta cuenta, escribimos los bytes serializados con Borsh directamente en su campo data:

¿Cómo se persiste esto en el almacenamiento?
storage_account.try_borrow_mut_data()? no nos da simplemente una copia. Nos da una referencia mutable al campo data de la cuenta real que reside en la blockchain de Solana. Así que cuando escribimos en account_data, estamos escribiendo directamente en el almacenamiento persistente de la cuenta.
Piénsalo de esta manera:
storage_accountes un manejador (handle) a una cuenta real que existe en Solana.try_borrow_mut_data()te da acceso directo a los bytes de data de esa cuenta.- Cuando modificas
account_data, estás modificando los datos reales de la cuenta on-chain usandocopy_from_slice(copia los bytes desdeserialized_datahaciaaccount_data). - El runtime de Solana persiste automáticamente estos cambios cuando tu programa finaliza exitosamente.
En este punto, el campo data de nuestra cuenta contiene exactamente los 8 bytes serializados con Borsh: [42, 0, 0, 0, 0, 0, 0, 0]. Eso es todo, nuestro struct ahora está “almacenado” en la cuenta, y persistirá en la blockchain incluso después de que nuestro programa termine de ejecutarse.
Ahora compila y despliega el programa:
cargo build-sbf
solana-test-validator # in another terminal
solana program deploy target/deploy/solana_storage_write.so
Copia el program ID de la salida del despliegue, lo usaremos cuando probemos el programa.
Probando el programa con un cliente
Ahora vamos a crear un cliente en TypeScript para probar nuestro programa de almacenamiento.
Al igual que en los tutoriales anteriores, configuramos el entorno del cliente desde el directorio raíz del proyecto:
mkdir client && cd client
npm init -y
npm install @solana/web3.js typescript ts-node @types/node
Actualiza client/package.json para agregar un script de prueba:
{
"scripts": {
"test": "ts-node client.ts"
}
}
Crea client/tsconfig.json para configurar los ajustes de compilación de TypeScript:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["*.ts"]
}
Ahora crea client/client.ts y agrega el siguiente código. En este cliente, nosotros:
- Creamos un keypair signer y una cuenta keypair de almacenamiento para nuestro struct
CounterData. - Financiamos la cuenta signer con SOL.
- Pasamos las cuentas requeridas a nuestro programa para la creación del almacenamiento (storage account, cuenta signer, System Program).
- Ejecutamos la transacción para crear la cuenta y escribir los datos.
- Leemos los datos de la cuenta nuevamente y verificamos que se escribieron correctamente (que el valor de
countsea exactamente 42).
import {
Connection,
Keypair,
LAMPORTS_PER_SOL,
PublicKey,
SystemProgram,
Transaction,
TransactionInstruction,
sendAndConfirmTransaction,
} from '@solana/web3.js';
const PROGRAM_ID = new PublicKey('YOUR_PROGRAM_ID_HERE'); // Replace with your actual program ID
const connection = new Connection('http://localhost:8899', 'confirmed');
async function testStorageWrite() {
console.log('Testing Storage Creation and Writing\n');
// Create a signer keypair and a storage keypair account
const signer = Keypair.generate();
const storageAccount = Keypair.generate();
// Fund the signer keypair account
console.log('Funding signer account...');
await connection.requestAirdrop(signer.publicKey, LAMPORTS_PER_SOL);
await new Promise(resolve => setTimeout(resolve, 2000));
console.log(`Signer: ${signer.publicKey.toString()}`);
console.log(`Storage Account: ${storageAccount.publicKey.toString()}\n`);
// Create instruction with required accounts (storage, signer & system program account)
const instruction = new TransactionInstruction({
keys: [
{ pubkey: storageAccount.publicKey, isSigner: true, isWritable: true },
{ pubkey: signer.publicKey, isSigner: true, isWritable: true },
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
],
programId: PROGRAM_ID,
data: Buffer.alloc(0), // No instruction data needed
});
const transaction = new Transaction().add(instruction);
console.log('Creating storage account and writing data...');
const signature = await sendAndConfirmTransaction(connection, transaction, [signer, storageAccount]);
console.log(`Transaction confirmed: ${signature}`);
// Verify the data was written correctly by reading it back
console.log('\nVerifying data was written correctly...');
const accountInfo = await connection.getAccountInfo(storageAccount.publicKey);
if (accountInfo && accountInfo.data.length > 0) {
console.log('Account data length:', accountInfo.data.length, 'bytes');
console.log('Raw account data:', Array.from(accountInfo.data));
// Deserialize the data back to verify
const deserializedData = new Uint8Array(accountInfo.data);
// DataView lets us read binary data as specific types (u64 in this case)
// getBigUint64(0, true) reads 8 bytes starting at offset 0, little-endian
const count = new DataView(deserializedData.buffer).getBigUint64(0, true);
console.log('Deserialized count value:', count.toString());
// the `n` makes 42 a BigInt literal. This is required because getBigUint64 returns a BigInt
if (count === 42n) {
console.log('Data was written correctly to the storage account.');
} else {
console.log('Error: Expected count 42, got', count.toString());
}
} else {
console.log('Error: Could not read account data');
}
}
testStorageWrite().catch(console.error);
Asegúrate de que la variable PROGRAM_ID esté establecida con tu program ID.
Entendiendo la creación de la cuenta keypair en nuestro cliente
Primero generamos un keypair que se convertirá en nuestra nueva cuenta:

El siguiente paso importante es establecer la cuenta como signer cuando construimos la instrucción:

El isSigner: true es crucial porque el System Program necesita una firma de la dirección exacta donde se creará la cuenta.
Luego proporcionamos los keypairs al enviar la transacción:

Los objetos keypair contienen las claves privadas necesarias para generar las firmas requeridas, autorizando la creación de la cuenta en esa dirección específica.
Antes de ejecutar la prueba, asegúrate de que tu validador local de Solana siga ejecutándose y que el programa se haya desplegado en él.
Ahora ejecuta la prueba:
cd client
npm run test
Deberías ver que la transacción se ejecuta exitosamente.

Nuestro programa se ejecutó exitosamente y creó una cuenta con el valor del contador en 42. También vemos el CounterData serializado de [42, 0, 0, 0, 0, 0, 0, 0] (8 bytes).
En la siguiente parte de este tutorial, crearemos una cuenta de almacenamiento utilizando un PDA en lugar de un keypair. Los fundamentos siguen siendo los mismos — solo difiere el mecanismo de firma.
Este artículo es parte de una serie de tutoriales sobre desarrollo en Solana.