Cómo escribir un programa con Rust Nativo

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.

lib.rs
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ón
  • accounts - 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.

Info

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.

native.test.ts
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));