Para escribir programas de Solana sin el marco de trabajo de Anchor, utilizamos
el crate
solana_program
. This
is the base library for writing onchain programs in Rust.
For beginners, it is recommended to start with the Anchor framework.
Programas #
A continuación se muestra un programa de Solana sencillo con una instrucción que crea una cuenta. Vamos paso a paso para explicar la estructura básica de un programa de Solana. Aquí está el programa en Solana Playground.
use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::{
account_info::{next_account_info, AccountInfo},
entrypoint,
entrypoint::ProgramResult,
msg,
program::invoke,
pubkey::Pubkey,
rent::Rent,
system_instruction::create_account,
sysvar::Sysvar,
};
entrypoint!(process_instruction);
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
let instruction = Instructions::try_from_slice(instruction_data)?;
match instruction {
Instructions::Initialize { data } => process_initialize(program_id, accounts, data),
}
}
pub fn process_initialize(
program_id: &Pubkey,
accounts: &[AccountInfo],
data: u64,
) -> ProgramResult {
let accounts_iter = &mut accounts.iter();
let new_account = next_account_info(accounts_iter)?;
let signer = next_account_info(accounts_iter)?;
let system_program = next_account_info(accounts_iter)?;
let account_data = NewAccount { data };
let size = account_data.try_to_vec()?.len();
let lamports = (Rent::get()?).minimum_balance(size);
invoke(
&create_account(
signer.key,
new_account.key,
lamports,
size as u64,
program_id,
),
&[signer.clone(), new_account.clone(), system_program.clone()],
)?;
account_data.serialize(&mut *new_account.data.borrow_mut())?;
msg!("Changed data to: {:?}!", data);
Ok(())
}
#[derive(BorshSerialize, BorshDeserialize)]
pub enum Instructions {
Initialize { data: u64 },
}
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct NewAccount {
pub data: u64,
}
Punto de entrada #
Los programas en Solana incluyen un único
entrypoint (punto de entrada)
utilizado para invocar el programa. La función
process_instruction
se utiliza entonces para procesar los datos pasados al entrypoint. Esta función
requiere los siguientes parámetros:
program_id
- Dirección del programa actualmente en ejecuciónaccounts
- Arreglo de cuentas necesarias para ejecutar una instrucción.instruction_data
- Datos serializados específicos de una instrucción.
entrypoint!(process_instruction);
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
...
}
Estos parámetros corresponden a los detalles requeridos para cada instrucción en una transacción.
Instrucciones #
Aunque sólo hay un punto de entrada, la ejecución del programa puede seguir
diferentes caminos dependiendo del instruction_data
. Es habitual definir
instrucciones como variantes dentro de un
enum, donde cada
variante representa una instrucción distinta en el programa.
#[derive(BorshSerialize, BorshDeserialize)]
pub enum Instructions {
Initialize { data: u64 },
}
El valor de instruction_data
que recibe el punto de entrada es deserializado
para determinar la variante del enum correspondiente.
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
let instruction = Instructions::try_from_slice(instruction_data)?;
match instruction {
Instructions::Initialize { data } => process_initialize(program_id, accounts, data),
}
}
A continuación, se utiliza match para invocar la función que incluye la lógica para procesar la instrucción identificada. Estas funciones suelen denominarse manejadores de instrucciones.
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
let instruction = Instructions::try_from_slice(instruction_data)?;
match instruction {
Instructions::Initialize { data } => process_initialize(program_id, accounts, data),
}
}
pub fn process_initialize(
program_id: &Pubkey,
accounts: &[AccountInfo],
data: u64,
) -> ProgramResult {
...
Ok(())
}
Proceso de la instrucción #
Para cada instrucción de un programa, existe una función específica de manejo de instrucciones que implementa la lógica necesaria para ejecutar esa instrucción.
pub fn process_initialize(
program_id: &Pubkey,
accounts: &[AccountInfo],
data: u64,
) -> ProgramResult {
let accounts_iter = &mut accounts.iter();
let new_account = next_account_info(accounts_iter)?;
let signer = next_account_info(accounts_iter)?;
let system_program = next_account_info(accounts_iter)?;
let account_data = NewAccount { data };
let size = account_data.try_to_vec()?.len();
let lamports = (Rent::get()?).minimum_balance(size);
invoke(
&create_account(
signer.key,
new_account.key,
lamports,
size as u64,
program_id,
),
&[signer.clone(), new_account.clone(), system_program.clone()],
)?;
account_data.serialize(&mut *new_account.data.borrow_mut())?;
msg!("Changed data to: {:?}!", data);
Ok(())
}
Para acceder a las cuentas proporcionadas al programa, utilice un
iterador para recorrer
la lista de cuentas recibidas en el punto de entrada a través del argumento
accounts
. La función
next_account_info
se utiliza para acceder al siguiente elemento del iterador.
let accounts_iter = &mut accounts.iter();
let new_account = next_account_info(accounts_iter)?;
let signer = next_account_info(accounts_iter)?;
let system_program = next_account_info(accounts_iter)?;
Para crear una nueva cuenta es necesario invocar la instrucción
create_account
en el programa del sistema. Cuando el
programa del sistema crea una cuenta nueva, puede modificar el programa
propietario de la cuenta creada.
En este ejemplo, usamos una invocación entre programas para
invocar el programa del sistema, creando una cuenta nueva donde el propietario
es el programa en ejecución. Como parte del
modelo de cuentas de Solana, solo el
programa designado como owner
(propietario) de una cuenta puede modificar los
datos de la cuenta.
let account_data = NewAccount { data };
let size = account_data.try_to_vec()?.len();
let lamports = (Rent::get()?).minimum_balance(size);
invoke(
&create_account(
signer.key, // payer
new_account.key, // nueva dirección de cuenta
lamports, // renta
size as u64, // espacio
program_id, // dirección del owner del programa
),
&[signer.clone(), new_account.clone(), system_program.clone()],
)?;
Una vez que la cuenta se ha creado correctamente, el último paso es serializar
los datos en el campo data
de la cuenta nueva. Esto inicializa efectivamente
los datos de la cuenta, almacenando la data
pasada al punto de entrada del
programa.
account_data.serialize(&mut *new_account.data.borrow_mut())?;
Estado #
Los struct
se utilizan para definir el formato de las cuentas de datos para un
programa. La serialización y deserialización de los datos de las cuentas se
realiza habitualmente utilizando Borsh.
En este ejemplo, el struct
de NewAccount
define la estructura de los datos a
almacenar en una cuenta nueva.
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct NewAccount {
pub data: u64,
}
Todas las cuentas de Solana incluyen un campo
data
que puede utilizarse para almacenar
cualquier dato arbitrario como un arreglo de bytes. Esta flexibilidad permite a
los programas crear y almacenar estructuras de datos personalizadas dentro de
las cuentas creadas.
En la función process_initialize
, los datos pasados al punto de entrada se
utilizan para crear una instancia del struct
de NewAccount
. Esta instancia
se serializa y almacena en el campo de datos de la cuenta recién creada.
pub fn process_initialize(
program_id: &Pubkey,
accounts: &[AccountInfo],
data: u64,
) -> ProgramResult {
let account_data = NewAccount { data };
invoke(
...
)?;
account_data.serialize(&mut *new_account.data.borrow_mut())?;
msg!("Changed data to: {:?}!", data);
Ok(())
}
...
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct NewAccount {
pub data: u64,
}
Cliente #
Interactuar con programas de Solana escritos en Rust nativo implica construir
directamente la
TransactionInstruction
.
Del mismo modo, la obtención y deserialización de datos de cuentas requiere la creación de un esquema compatible con las estructuras de datos del programa.
Puedes hacer clientes para programas de Solana en varios lenguajes de programación distintos. Puedes encontrar detalles para clientes en Rust y Javascript/Typescript en la sección de Clientes de la documentación de Solana.
A continuación, veremos un ejemplo que demuestra cómo invocar la instrucción
initialize
del programa anterior.
describe("Test", () => {
it("Initialize", async () => {
// Generar par de llaves para la cuenta nueva
const newAccountKp = new web3.Keypair();
const instructionIndex = 0;
const data = 42;
// Crear el buffer de la data de la instruction
const instructionData = Buffer.alloc(1 + 8);
instructionData.writeUInt8(instructionIndex, 0);
instructionData.writeBigUInt64LE(BigInt(data), 1);
const instruction = new web3.TransactionInstruction({
keys: [
{
pubkey: newAccountKp.publicKey,
isSigner: true,
isWritable: true,
},
{
pubkey: pg.wallet.publicKey,
isSigner: true,
isWritable: true,
},
{
pubkey: web3.SystemProgram.programId,
isSigner: false,
isWritable: false,
},
],
programId: pg.PROGRAM_ID,
data: instructionData,
});
const transaction = new web3.Transaction().add(instruction);
const txHash = await web3.sendAndConfirmTransaction(
pg.connection,
transaction,
[pg.wallet.keypair, newAccountKp],
);
console.log(`Use 'solana confirm -v ${txHash}' to see the logs`);
// Obtener la cuenta
const newAccount = await pg.connection.getAccountInfo(
newAccountKp.publicKey,
);
// Deserializar data de la cuenta
const deserializedAccountData = borsh.deserialize(
AccountDataSchema,
AccountData,
newAccount.data,
);
console.log(Number(deserializedAccountData.data));
});
});
class AccountData {
data = 0;
constructor(fields: { data: number }) {
if (fields) {
this.data = fields.data;
}
}
}
const AccountDataSchema = new Map([
[AccountData, { kind: "struct", fields: [["data", "u64"]] }],
]);
Invocar instrucciones #
Para invocar una instrucción, debe construir manualmente una
TransactionInstruction
que corresponda con el programa. Esto incluye que se
especifique:
- El ID del programa que se está invocando
- La
AccountMeta
de cada cuenta requerida por la instrucción - El buffer de datos de instrucción requerido por la instrucción
// Genera el par de llaves para la cuenta nueva
const newAccountKp = new web3.Keypair();
const instructionIndex = 0;
const data = 42;
// Crea instrucción de data buffer
const instructionData = Buffer.alloc(1 + 8);
instructionData.writeUInt8(instructionIndex, 0);
instructionData.writeBigUInt64LE(BigInt(data), 1);
const instruction = new web3.TransactionInstruction({
keys: [
{
pubkey: newAccountKp.publicKey,
isSigner: true,
isWritable: true,
},
{
pubkey: pg.wallet.publicKey,
isSigner: true,
isWritable: true,
},
{
pubkey: web3.SystemProgram.programId,
isSigner: false,
isWritable: false,
},
],
programId: pg.PROGRAM_ID,
data: instructionData,
});
En primer lugar, crea un nuevo keypair (par de llaves). La llave pública de este
par de llaves se utilizará como dirección para la cuenta creada por la
instrucción initialize
.
// Genera el par de llaves para la cuenta nueva
const newAccountKp = new web3.Keypair();
Antes de construir la instrucción, prepara el buffer de datos de instrucción que
espera la instrucción. En este ejemplo, el primer byte del buffer identifica la
instrucción a invocar en el programa. Los 8 bytes adicionales se asignan a los
datos de tipo u64
, necesarios para la instrucción initialize
.
const instructionIndex = 0;
const data = 42;
// Crea instrucción de data buffer
const instructionData = Buffer.alloc(1 + 8);
instructionData.writeUInt8(instructionIndex, 0);
instructionData.writeBigUInt64LE(BigInt(data), 1);
Después de crear el buffer de datos de la instrucción, utilízalo para construir
la TransactionInstruction
. Esto implica especificar el ID del programa y
definir la AccountMeta
para cada
cuenta implicada en la instrucción. Esto significa especificar si cada cuenta es
mutable y si se requiere su firma en la transacción.
const instruction = new web3.TransactionInstruction({
keys: [
{
pubkey: newAccountKp.publicKey,
isSigner: true,
isWritable: true,
},
{
pubkey: pg.wallet.publicKey,
isSigner: true,
isWritable: true,
},
{
pubkey: web3.SystemProgram.programId,
isSigner: false,
isWritable: false,
},
],
programId: pg.PROGRAM_ID,
data: instructionData,
});
Por último, se añade la instrucción a una transacción y se envía para que sea procesada por la red.
const transaction = new web3.Transaction().add(instruction);
const txHash = await web3.sendAndConfirmTransaction(
pg.connection,
transaction,
[pg.wallet.keypair, newAccountKp],
);
console.log(`Use 'solana confirm -v ${txHash}' to see the logs`);
Obtener cuentas #
Para obtener y deserializar los datos de la cuenta, primero debe crear un esquema que coincida con los datos de la cuenta en el programa.
class AccountData {
data = 0;
constructor(fields: { data: number }) {
if (fields) {
this.data = fields.data;
}
}
}
const AccountDataSchema = new Map([
[AccountData, { kind: "struct", fields: [["data", "u64"]] }],
]);
A continuación, obtenga el AccountInfo
de la cuenta utilizando su dirección.
const newAccount = await pg.connection.getAccountInfo(newAccountKp.publicKey);
Por último, deserializa el campo data
de AccountInfo
utilizando el esquema
predefinido.
const deserializedAccountData = borsh.deserialize(
AccountDataSchema,
AccountData,
newAccount.data,
);
console.log(Number(deserializedAccountData.data));