Saltar al contenido principal

Tokens

El ejemplo de token demuestra cómo escribir un contrato de token que implementa la Interfaz de Token.

Abrir en Gitpod

Ejecutar el Ejemplo

Primero pasa por el proceso de Configuración para tener tu entorno de desarrollo configurado, luego clona la etiqueta v21.6.0 del repositorio soroban-examples:

git clone -b v21.6.0 https://github.com/stellar/soroban-examples

O, salta la configuración del entorno de desarrollo y abre este ejemplo en Gitpod.

Para ejecutar las pruebas del ejemplo, navega al directorio hello_world, y usa cargo test.

cd token
cargo test

Deberías ver la salida:

running 8 tests
test test::initialize_already_initialized - should panic ... ok
test test::transfer_spend_deauthorized - should panic ... ok
test test::decimal_is_over_eighteen - should panic ... ok
test test::test_burn ... ok
test test::transfer_receive_deauthorized - should panic ... ok
test test::transfer_from_insufficient_allowance - should panic ... ok
test test::transfer_insufficient_balance - should panic ... ok
test test::test ... ok

Código

nota

El código fuente para este ejemplo de token está dividido en varios módulos más pequeños. Este es un patrón de diseño común para contratos inteligentes más complejos.

token/src/lib.rs
#![no_std]

mod admin;
mod allowance;
mod balance;
mod contract;
mod event;
mod metadata;
mod storage_types;
mod test;

pub use crate::contract::TokenClient;

Referencia: https://github.com/stellar/soroban-examples/tree/v21.6.0/token

Cómo Funciona

Los tokens creados en una plataforma de contrato inteligente pueden tomar muchas formas diferentes, incluir una variedad de funcionalidades diferentes, y satisfacer necesidades o casos de uso muy diferentes. Si bien cada token puede cumplir con un nicho único, hay algunas características "normales" que casi todos los tokens necesitarán utilizar (por ejemplo, pagos, transferencias, consultas de saldo, etc.). Con el fin de minimizar la repetición y agilizar las implementaciones de tokens, Soroban implementa la Interfaz de Token, que proporciona una interfaz uniforme y predecible para desarrolladores y usuarios.

Crear un contrato compatible con Soroban a partir de un activo Stellar existente es muy fácil, requiere desplegar el Contrato de Activo Stellar incorporado.

Este contrato de ejemplo, sin embargo, demuestra cómo podría construirse un token de contrato inteligente que no aprovecha el Contrato de Activo Stellar, pero que aún satisface la Interfaz de Token de uso común para maximizar la interoperabilidad.

Separación de Funcionalidad

Probablemente hayas notado que este contrato de ejemplo está dividido en módulos discretos, cada uno responsable de un conjunto silo de funcionalidades. Esta práctica común ayuda a organizar el código y hacerlo más mantenible.

Por ejemplo, la mayor parte de la lógica del token existe en el módulo contract.rs. Funciones como mint, burn, transfer, etc. están escritas y programadas en ese archivo. La Interfaz de Token describe cómo algunas de estas funciones deberían emitir eventos cuando ocurren. Sin embargo, mantener toda esa lógica de emisión de eventos agrupada con el resto del código del contrato podría dificultar el seguimiento de lo que está sucediendo en el código, y esa confusión podría finalmente llevar a errores.

En cambio, tenemos un módulo separado events.rs que elimina todo el dolor de cabeza de emitir eventos cuando se ejecutan otras funciones. Aquí está la función para emitir un evento cada vez que se emite el token:

pub(crate) fn mint(e: &Env, admin: Address, to: Address, amount: i128) {
let topics = (symbol_short!("mint"), admin, to);
e.events().publish(topics, amount);
}

Aunque este es un ejemplo simple, construir el contrato de esta manera deja muy claro al desarrollador lo que está sucediendo y dónde. Esta función se utiliza luego por el módulo contract.rs cada vez que se invoca la función mint:

// earlier in `contract.rs`
use crate::event;

fn mint(e: Env, to: Address, amount: i128) {
check_nonnegative_amount(amount);
let admin = read_administrator(&e);
admin.require_auth();
receive_balance(&e, to.clone(), amount);
event::mint(&e, admin, to, amount);
}

Esta misma convención se utiliza para separar del código del "contrato principal" la metadata para el token, las definiciones de tipos de almacenamiento, etc.

Interfaz Estandarizada, Comportamiento Personalizado

Este contrato de ejemplo sigue la Interfaz de Token estandarizada, implementando todas las mismas funciones que el Contrato de Activo Stellar. Esto le da a las billeteras, usuarios, desarrolladores, etc. una interfaz predecible para interactuar con el token. Aún cuando estamos implementando la misma interfaz de funciones, eso no significa que tengamos que implementar el mismo comportamiento dentro de esas funciones. Si bien este contrato de ejemplo no modifica realmente ninguna de las funciones que estarían presentes en una instancia desplegada del Contrato de Activo Stellar, esa posibilidad sigue abierta para el desarrollador del contrato.

Por ejemplo, quizás tengas un proyecto NFT, y el artista quiere que se pague una pequeña regalía cada vez que su token cambia de manos:

// This is mainly the `transfer` function from `src/contract.rs`
fn transfer(e: Env, from: Address, to: Address, amount: i128) {
from.require_auth();

check_nonnegative_amount(amount);
spend_balance(&e, from.clone(), amount);
// We calculate some new amounts for payment and royalty
let payment = (amount * 997) / 1000;
let royalty = amount - payment
receive_balance(&e, artist.clone(), royalty);
receive_balance(&e, to.clone(), payment);
event::transfer(&e, from, to, amount);
}

La interfaz transfer sigue en uso, y sigue siendo la misma que otros tokens, pero hemos personalizado el comportamiento para abordar una necesidad específica. Otro caso de uso podría ser un token controlado estrictamente que requiere autenticación de un administrador antes de que cualquier función de transfer, allowance, etc. pueda ser invocada.

consejo

Por supuesto, querrás que tu token se comporte de manera intuitiva y transparente. Si un usuario está invocando un transfer, esperará que los tokens se transfieran. Si un emisor de activos necesita invocar un clawback, probablemente requerirá que se produzca el comportamiento adecuado.

Pruebas

Abre el archivo token/src/test.rs para seguir.

token/src/test.rs
#![cfg(test)]
extern crate std;

use crate::{contract::Token, TokenClient};
use soroban_sdk::{testutils::Address as _, Address, Env, IntoVal, Symbol};

fn create_token<'a>(e: &Env, admin: &Address) -> TokenClient<'a> {
let token = TokenClient::new(e, &e.register_contract(None, Token {}));
token.initialize(admin, &7, &"name".into_val(e), &"symbol".into_val(e));
token
}

#[test]
fn test() {
let e = Env::default();
e.mock_all_auths();

let admin1 = Address::random(&e);
let admin2 = Address::random(&e);
let user1 = Address::random(&e);
let user2 = Address::random(&e);
let user3 = Address::random(&e);
let token = create_token(&e, &admin1);

token.mint(&user1, &1000);
assert_eq!(
e.auths(),
std::vec![(
admin1.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("mint"),
(&user1, 1000_i128).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);
assert_eq!(token.balance(&user1), 1000);

token.approve(&user2, &user3, &500, &200);
assert_eq!(
e.auths(),
std::vec![(
user2.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("approve"),
(&user2, &user3, 500_i128, 200_u32).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);
assert_eq!(token.allowance(&user2, &user3), 500);

token.transfer(&user1, &user2, &600);
assert_eq!(
e.auths(),
std::vec![(
user1.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("transfer"),
(&user1, &user2, 600_i128).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);
assert_eq!(token.balance(&user1), 400);
assert_eq!(token.balance(&user2), 600);

token.transfer_from(&user3, &user2, &user1, &400);
assert_eq!(
e.auths(),
std::vec![(
user3.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
Symbol::new(&e, "transfer_from"),
(&user3, &user2, &user1, 400_i128).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);
assert_eq!(token.balance(&user1), 800);
assert_eq!(token.balance(&user2), 200);

token.transfer(&user1, &user3, &300);
assert_eq!(token.balance(&user1), 500);
assert_eq!(token.balance(&user3), 300);

token.set_admin(&admin2);
assert_eq!(
e.auths(),
std::vec![(
admin1.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("set_admin"),
(&admin2,).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);

token.set_authorized(&user2, &false);
assert_eq!(
e.auths(),
std::vec![(
admin2.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
Symbol::new(&e, "set_authorized"),
(&user2, false).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);
assert_eq!(token.authorized(&user2), false);

token.set_authorized(&user3, &true);
assert_eq!(token.authorized(&user3), true);

token.clawback(&user3, &100);
assert_eq!(
e.auths(),
std::vec![(
admin2.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("clawback"),
(&user3, 100_i128).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);
assert_eq!(token.balance(&user3), 200);

// Set allowance to 500
token.approve(&user2, &user3, &500, &200);
assert_eq!(token.allowance(&user2, &user3), 500);
token.approve(&user2, &user3, &0, &200);
assert_eq!(
e.auths(),
std::vec![(
user2.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("approve"),
(&user2, &user3, 0_i128, 200_u32).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);
assert_eq!(token.allowance(&user2, &user3), 0);
}

#[test]
fn test_burn() {
let e = Env::default();
e.mock_all_auths();

let admin = Address::random(&e);
let user1 = Address::random(&e);
let user2 = Address::random(&e);
let token = create_token(&e, &admin);

token.mint(&user1, &1000);
assert_eq!(token.balance(&user1), 1000);

token.approve(&user1, &user2, &500, &200);
assert_eq!(token.allowance(&user1, &user2), 500);

token.burn_from(&user2, &user1, &500);
assert_eq!(
e.auths(),
std::vec![(
user2.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("burn_from"),
(&user2, &user1, 500_i128).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);

assert_eq!(token.allowance(&user1, &user2), 0);
assert_eq!(token.balance(&user1), 500);
assert_eq!(token.balance(&user2), 0);

token.burn(&user1, &500);
assert_eq!(
e.auths(),
std::vec![(
user1.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("burn"),
(&user1, 500_i128).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);

assert_eq!(token.balance(&user1), 0);
assert_eq!(token.balance(&user2), 0);
}

#[test]
#[should_panic(expected = "insufficient balance")]
fn transfer_insufficient_balance() {
let e = Env::default();
e.mock_all_auths();

let admin = Address::random(&e);
let user1 = Address::random(&e);
let user2 = Address::random(&e);
let token = create_token(&e, &admin);

token.mint(&user1, &1000);
assert_eq!(token.balance(&user1), 1000);

token.transfer(&user1, &user2, &1001);
}

#[test]
#[should_panic(expected = "can't receive when deauthorized")]
fn transfer_receive_deauthorized() {
let e = Env::default();
e.mock_all_auths();

let admin = Address::random(&e);
let user1 = Address::random(&e);
let user2 = Address::random(&e);
let token = create_token(&e, &admin);

token.mint(&user1, &1000);
assert_eq!(token.balance(&user1), 1000);

token.set_authorized(&user2, &false);
token.transfer(&user1, &user2, &1);
}

#[test]
#[should_panic(expected = "can't spend when deauthorized")]
fn transfer_spend_deauthorized() {
let e = Env::default();
e.mock_all_auths();

let admin = Address::random(&e);
let user1 = Address::random(&e);
let user2 = Address::random(&e);
let token = create_token(&e, &admin);

token.mint(&user1, &1000);
assert_eq!(token.balance(&user1), 1000);

token.set_authorized(&user1, &false);
token.transfer(&user1, &user2, &1);
}

#[test]
#[should_panic(expected = "insufficient allowance")]
fn transfer_from_insufficient_allowance() {
let e = Env::default();
e.mock_all_auths();

let admin = Address::random(&e);
let user1 = Address::random(&e);
let user2 = Address::random(&e);
let user3 = Address::random(&e);
let token = create_token(&e, &admin);

token.mint(&user1, &1000);
assert_eq!(token.balance(&user1), 1000);

token.approve(&user1, &user3, &100, &200);
assert_eq!(token.allowance(&user1, &user3), 100);

token.transfer_from(&user3, &user1, &user2, &101);
}

#[test]
#[should_panic(expected = "already initialized")]
fn initialize_already_initialized() {
let e = Env::default();
let admin = Address::random(&e);
let token = create_token(&e, &admin);

token.initialize(&admin, &10, &"name".into_val(&e), &"symbol".into_val(&e));
}

#[test]
#[should_panic(expected = "Decimal must not be greater than 18")]
fn decimal_is_over_eighteen() {
let e = Env::default();
let admin = Address::generate(&e);
let token = TokenClient::new(&e, &e.register_contract(None, Token {}));
token.initialize(&admin, &19, &"name".into_val(&e), &"symbol".into_val(&e));
}

El ejemplo de token implementa ocho pruebas diferentes para cubrir una amplia gama de posibles comportamientos y problemas. Sin embargo, todas las pruebas comienzan con algunas piezas comunes. En cualquier prueba, lo primero que siempre se requiere es un Env, que es el entorno Soroban en el que se ejecutará el contrato.

let e = Env::default();

Simulamos verificaciones de autenticación en las pruebas, lo que permite que las pruebas continúen como si todos los usuarios/direcciones/contratos/etc. se hubieran autenticado con éxito.

e.mock_all_auths();

También estamos utilizando una función create_token para facilitar la repetición de tener que registrar e inicializar nuestro contrato de token. El cliente token resultante se utiliza para invocar el contrato durante cada prueba.

// It is defined at the top of the file...
fn create_token<'a>(e: &Env, admin: &Address) -> TokenClient<'a> {
let token = TokenClient::new(e, &e.register_contract(None, Token {}));
token.initialize(admin, &7, &"name".into_val(e), &"symbol".into_val(e));
token
}

// ... and it is used inside each test
let token = create_token(&e, &admin);

Todas las funciones públicas dentro de un bloque impl que ha sido anotado con el atributo #[contractimpl] tendrán una función correspondiente en el tipo de cliente generado de la prueba. El tipo de cliente se llamará igual que el tipo de contrato con Client añadido. Por ejemplo, en nuestro contrato, el tipo de contrato se llama Token, y el tipo de cliente se llama TokenClient.

Las ocho pruebas creadas para este contrato de ejemplo prueban una variedad de condiciones posibles y aseguran que el contrato responda adecuadamente a cada una:

  • test() - Esta función hace uso de una variedad de las funciones de token incorporadas para probar la forma "predecible" en que un activo podría ser interactuado por un usuario, así como un administrador.
  • test_burn() - Esta función asegura que una invocación de burn() disminuye el saldo de un usuario, y que una invocación de burn_from() disminuye el saldo de un usuario así como consume la asignación de otro usuario de ese saldo.
  • transfer_insufficient_balance() - Esta función asegura que una invocación de transfer() provoca un pánico cuando el usuario from no tiene el saldo para cubrirlo.
  • transfer_receive_deauthorized() - Esta función asegura que un usuario que está específicamente desautorizado para poseer el token no pueda ser el beneficiario de una invocación de transfer().
  • transfer_spend_deauthorized() - Esta función asegura que un usuario con un saldo de token, que posteriormente es desautorizado, no pueda ser la fuente de una invocación de transfer().
  • transfer_from_insufficient_allowance() - Esta función asegura que un usuario con una asignación existente para el saldo de otro no pueda hacer una transfer() mayor que esa asignación.
  • initialize_already_initialized() - Esta función verifica que el contrato no puede invocar su función initialize() por segunda vez.
  • decimal_is_over_eighteen() - Esta función prueba que invocar initialize() con una precisión decimal demasiado alta no tendrá éxito.

Construir el Contrato

Para crear el contrato, utiliza el comando stellar contract build.

stellar contract build

Un archivo .wasm debería ser generado en el directorio target:

target/wasm32-unknown-unknown/release/soroban_token_contract.wasm

Ejecutar el Contrato

Si tienes stellar-cli instalado, puedes invocar funciones del contrato usando eso.

stellar contract invoke \
--wasm target/wasm32-unknown-unknown/release/soroban_token_contract.wasm \
--id 1 \
-- \
balance \
--id GBZV3NONYSUDVTEHATQO4BCJVFXJO3XQU5K32X3XREVZKSMMOZFO4ZXR