Esta es la segunda parte de una serie de publicaciones sobre la creación de una aplicación de chat utilizando Rust en la cadena de bloques Near. Puedes encontrar la primera publicación de la serie aquí.
En esta publicación nos centraremos en el contrato inteligente en sí. Veremos la biblioteca near-sdk que hace que nuestro código funcione en Near. También veremos patrones de acceso de estado de Near y los principios del desarrollo de contratos inteligentes en acción al revisar el código de este contrato inteligente. Puedes encontrar el repositorio completo con todo el código que discutiremos hoy en mi GitHub.
El SDK Para Crear Contratos Inteligentes De Near En Rust
Internamente, el tiempo de ejecución del contrato inteligente de Near utiliza WebAssembly (Wasm). Wasm es un formato de bytecode (el bytecode es un conjunto de instrucciones de bajo nivel que representa el código fuente de un contrato inteligente) bien establecido, que también se usa fuera de blockchain, como en aplicaciones web. Esto es bueno para Near porque su tiempo de ejecución puede beneficiarse del trabajo que se realiza en la comunidad más amplia de Wasm.
El compilador de Rust hace un buen trabajo al generar el código resultante en Wasm, pero debe haber algo de trabajo a su alrededor para que el código de bytes de Wasm funcione correctamente con su “host” (en nuestro caso es el tiempo de ejecución de Near, o en el caso de una aplicación web el motor de JavaScript de un navegador web). Este trabajo restante se puede generar automáticamente utilizando bibliotecas de Rust convenientes: wasm-bindgen en el caso de la integración del navegador y near-sdk en el caso de Near. El contrato inteligente con el que estamos trabajando hoy está escrito usando near-sdk.
Ambas bibliotecas utilizan macros de procedimiento de Rust (proc macros). Este es un tipo de metaprogramación donde la biblioteca define pequeñas anotaciones que podemos usar para activar el código Rust para que se genere automáticamente para nosotros. Las macros de procedimiento de Rust se utilizan para reducir la cantidad de código repetitivo que el desarrollador necesita escribir para que su lógica de negocios funcione. Por ejemplo, la macro de procedimiento derive es fundamental para el lenguaje Rust. Puedes definir automáticamente la funcionalidad común en los nuevos tipos de datos que crees. Puedes ver esto siendo utilizado en el siguiente fragmento de código simple del contrato inteligente de chat:
#[derive( Debug, BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, )] #[serde(crate = “near_sdk::serde”)] pub enum MessageStatus { Read, Unread, }
Puedes ver muchos rasgos enumerados en la anotación derive. Para mencionar algunos específicos: Debug significa que el tipo MessageStatus se puede convertir en una cadena de texto para ayudar en la depuración del código; Clone significa que es posible crear una instancia de MessageStatus idéntica a partir de la actual, y Copy significa que la operación de Clone es barata; PartialEq y Eq significan que puede comparar dos instancias de MessageStatus para ver si son iguales. Los rasgos Serialize y Deserialize provienen de la biblioteca serde, que es omnipresente en el ecosistema de Rust para codificar/decodificar datos de formatos como JSON o CBOR. Volveremos a los rasgos de Borsh más tarde.
Hasta ahora, todo esto ha sido Rust estándar que encontrarás en cualquier proyecto. La macro de procedimiento específica de Near es near_bindgen, que puedes ver utilizada en el siguiente fragmento de código:
#[near_bindgen] #[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] pub struct MessengerContract { accounts: LookupMap<AccountId, AccountStatus>, messages: LookupMap<MessageId, Message>, unread_messages: UnorderedSet<MessageId>, read_messages: UnorderedSet<MessageId>, last_received_message: LookupMap<AccountId, MessageId>, pending_contacts: UnorderedSet<AccountId>, owner: AccountId, }
La macro de procedimiento near_bindgen genera automáticamente el código adicional que necesitamos para que cuando compilemos en Wasm obtengamos una salida que el tiempo de ejecución de Near sepa cómo usar. Se usa en múltiples lugares donde se necesita dicho código extra. Aquí se usa para que la estructura MessengerContract sea proveída con el estado necesario para ejecutar los métodos del contrato. Se creará una instancia de la estructura MessengerContract cada vez que llamemos a un método en nuestro contrato inteligente. Discutiremos para qué se usan algunos de estos campos más adelante.
La macro near_bindgen también se usa sobre el “impl block” (Bloque de código de implementación) para la estructura MessengerContract:
#[near_bindgen] impl MessengerContract { // … }
Aquí significa que las funciones definidas en este bloque son los métodos que queremos exponer en nuestro contrato inteligente. Permite a los usuarios de la blockchain Near enviar transacciones llamando a estas funciones por su nombre. Por ejemplo, el método para enviar un mensaje se define en este bloque. Veremos algunos otros métodos de este bloque con más detalle a continuación.
En resumen, la biblioteca de rust near-sdk proporciona una macro de procedimiento llamada near_bindgen para generar automáticamente un código de conexión que hace que la salida de Wasm funcione con el tiempo de ejecución de Near. Esta macro se puede usar en una estructura para definir el estado de su contrato y en el bloque impl de esa estructura para definir los métodos públicos en su contrato. Near-sdk también proporciona otras funciones y estructuras útiles, que veremos en las siguientes secciones.
Estado Del Contrato Inteligente
Esencialmente, todos los contratos inteligentes no muy complicados requieren algún estado para funcionar correctamente. Por ejemplo, un contrato de token debe mantener los saldos de los distintos titulares de tokens. Nuestro contrato de chat no es diferente. Vimos en la sección anterior que la estructura MessengerContract contenía muchos campos. En esta sección, analizamos algunas características generales del estado en el tiempo de ejecución de Near, así como algunos detalles sobre cómo se usa en el contrato inteligente de ejemplo.
Lo más importante que debes saber sobre el estado del contrato inteligente en Near es que es un simple almacenamiento de clave-valor. Puedes ver esto en las funciones storage_read y storage_write de bajo nivel que se encuentran expuestas por near-sdk. Sin embargo, puedes construir algunas estructuras de datos más sofisticadas sobre esta base simple, y near-sdk proporciona algunas de estas en su módulo de colecciones. Por esta razón, nuestro contrato de ejemplo no utiliza el almacenamiento de clave-valor directamente; en su lugar, hace uso de las colecciones de nivel superior que ofrece near-sdk.
Por ejemplo, el contrato inteligente realiza un seguimiento del estado de las cuentas que conoce (cuáles de ellas son contratos, a cuáles hemos enviado una solicitud de contacto, etc.). El campo de cuentas de MessengerContract es una estructura de tipo LookupMap de near-sdk. Esto está bastante parecido a usar directamente el almacenamiento de clave-valor, ya que el mapa también es simplemente una forma de buscar un valor de una clave, pero LookupMap hace dos cosas importantes por encima de la interfaz de almacenamiento de clave-valor predeterminado. Primero tiene un prefijo que incluye en todas las claves de almacenamiento relacionadas con este mapa. El uso de un prefijo evita mezclar claves de este mapa con claves de otro (por ejemplo, el mapa last_received_message, que también está ingresado en AccountId). En segundo lugar, LookupMap nos permite trabajar con tipos de Rust de nivel superior, mientras que la interfaz de almacenamiento sin formato funciona solo con bytes. Esto se logra mediante el uso de la serialización de Borsh para convertir los tipos hacia y desde cadenas binarias. Borsh es un formato de serialización diseñado por Near para ser útil específicamente en aplicaciones de cadena de bloques. Este uso de Borsh es la razón por la que ve BorshDeserialize y BorshSerialize derivados de muchos tipos a lo largo del código.
Un ejemplo más interesante de una colección que se usa aquí es UnorderedSet , el cual se usa en el campo unread_messages. Esto es utilizado por el contrato para realizar un seguimiento de los mensajes que aún no se han leído. El UnorderedSet todavía se basa en el almacenamiento subyacente de clave-valor, pero efectivamente solo usa las claves, ya que solo nos importa si un elemento está en el conjunto o no. La estructura también guarda metadatos sobre qué claves está usando para permitirnos iterar sobre todas las claves del conjunto.
Comprobando el Entorno y Llamando a Otros Contratos.
En esta sección, analizamos las características generales del entorno de tiempo de ejecución de Near y la realización de llamadas entre contratos. Para darte mayor información, esto se hace en el contexto de cómo los usuarios se agregan entre sí como contactos en nuestra aplicación de chat. Echemos un vistazo a la definición de la función add_contact (esta definición está en el bloque impl de MessengerContact, con la anotación near_bindgen mencionada anteriormente, porque es un punto de entrada principal para nuestro contrato).
#[payable] pub fn add_contact(&mut self, account: AccountId) –> Promise { self.require_owner_only(); let deposit = env::attached_deposit(); require!(deposit >= ADD_CONTACT_DEPOSIT, “Insufficient deposit”); let this = env::current_account_id(); Self::ext(account.clone()) .with_attached_deposit(deposit) .ext_add_contact() .then(Self::ext(this).add_contact_callback(account)) }
Hay mucho que entender en estas pocas líneas de código. Como marco adicional para guiar nuestra discusión, recuerda los tres principios del desarrollo de contratos inteligentes descritos en la publicación anterior:
- Una mentalidad adversaria,
- Economía,
- Asegurar invariantes antes de hacer llamadas de contratos cruzados.
Regresa y revisa la primera publicación si necesitas un repaso sobre de qué se trataban estos principios. Cada uno de estos principios hace acto de presencia en esta función.
Una Mentalidad Adversaria
Todos los métodos de contrato inteligente son públicos y debemos hacer cumplir el control de acceso cuando el método realiza una acción confidencial, de lo contrario, alguien hará un mal uso de la funcionalidad. En este caso, no queremos que nadie pueda agregar contactos en nombre del propietario; solo el propietario debe poder decidir con quién conectarse (si alguien más quiere hacer contactos en la red de chat, ¡puede implementar este contrato en su propia cuenta!). Por lo tanto, tenemos la llamada require_owner_only() justo en la parte superior del cuerpo de la función. La implementación de esta función es simple:
fn require_owner_only(&self) –> AccountId { let predecessor_account = env::predecessor_account_id(); require!( self.owner == predecessor_account, “Only the owner can use this method!” ); predecessor_account }
Hacemos uso de la función predecessor_account_id del módulo env de near-sdk. Los módulos env contienen muchas funciones útiles para consultar aspectos del entorno de Near runtime en el que se ejecuta nuestro contrato. Por ejemplo, aquí estamos comprobando qué cuenta realizó la llamada a nuestro contrato. El módulo env contiene otras funciones útiles, como verificar la identificación de la cuenta de nuestro contrato y cuántos tokens Near se adjuntaron a esta llamada. Recomiendo leer la documentación del módulo para ver todas las funciones que están disponibles.
Por razones de eficiencia, la función require_owner_only también devuelve la cuenta predecesora (para evitar múltiples llamadas a env::predecessor_account_id() en caso de que una función qué requiera ser llamada sólo por el propietario también necesite la cuenta predecesora por otro motivo).
Economía
La primera línea del fragmento de código add_contact anterior incluye el atributo payable (pagable). El uso de esta anotación está habilitado ya qué la función se está definiendo como parte del bloque de implementación near_bindgen. Significa que este método aceptará tokens Near de los usuarios que lo llamen. Estos tokens son necesarios porque tomamos la decisión de que los usuarios paguen por acciones como crear un estado en la cadena. Dado que agregar otra cuenta como contacto crea un estado en su contrato y en el nuestro (debemos informarles que queremos conectarnos), debemos asegurarnos de que el usuario que inicia esta conexión pague por ese almacenamiento. El depósito adjunto a esta función pagadera se utiliza para cubrir ese costo de almacenamiento.
Puedes ver unas líneas más abajo donde verificamos que el depósito esté efectivamente presente. Esto hace uso de la función attached_deposit del módulo env. El hecho de que estemos haciendo esta verificación temprano se relaciona perfectamente con el tercer principio.
Asegurar Invariantes Antes De Hacer Llamadas De Contratos Cruzados
Es importante tener en cuenta la firma de tipo de la función add_contact. Primero, los argumentos de la función (&mut self, account: AccountId) significan que esta es una llamada mutable (cambiará el estado del contrato) y toma un argumento llamado “account” que debe ser un ID de cuenta de Near. Cuando near_bindgen haga su magia, significará que los usuarios de Near blockchain pueden llamar a esta función al realizar una transacción que toma un argumento codificado en JSON como { “account”: “my.account.near” }. En segundo lugar, el tipo de respuesta es Promise, lo que significa que estamos realizando una llamada de contrato cruzado al final de esta función. Las llamadas de contratos cruzados en Near son asincrónicas y no atómicas, por lo tanto, debemos asegurarnos de que todo esté en buen estado antes de realizar la llamada. Esta es la razón por la que incluimos la parte en la que comprobamos el depósito y la comprobación de qué solo el dueño de la cuenta está realizando la llamada en el cuerpo de la función. La naturaleza asíncrona de las llamadas de contratos cruzados también significa que esta función no devuelve ningún valor inmediatamente. La llamada asincrónica se realizará y el resultado solo llegará más tarde, después de que ocurra esta llamada.
Puedes ver los detalles de la llamada de contrato cruzado en la parte inferior de la función. Utiliza la API de alto nivel de near-sdk (aunque la API de bajo nivel también está disponible en el módulo env) donde near_bindgen genera automáticamente la función ext y devuelve una estructura de datos para construir la llamada de contrato cruzado. Puedes ver que primero usamos ext(account) para llamar a la cuenta que queremos agregar como contacto. La llamada incluye nuestro depósito a través de with_attached_deposit y está llamando a la función ext_add_contact (que se define en el mismo bloque de implementación en este caso, pero en general podría definirse en cualquier lugar). Finalmente, llamamos a then, lo que significa incluir una devolución de llamada. La devolución de llamada es en sí misma otra Promesa, por lo que usamos la misma función ext nuevamente, pero esta vez llamando a nuestra propia ID de cuenta. Esto se hace para que nuestro contrato pueda saber cuál fue la respuesta del contrato que estamos tratando de agregar como contacto. No entraré en los detalles de las implementaciones ext_add_contact o add_contact_callback aquí (simplemente manipulan el almacenamiento según el estado actual de la cuenta), pero te invito a leerlas en el código fuente en GitHub si estás interesado.
Resumen
¡En esta publicación nos sumergimos de cabeza en algo de código! Vimos cómo se usa near_bindgen para generar automáticamente el código necesario para ejecutar nuestro contrato en el tiempo de ejecución de Near, así como otras funciones de near-sdk para interactuar con el almacenamiento, el entorno de tiempo de ejecución y otros contratos. En la próxima publicación, continuaremos profundizando en el código, pero cambiaremos de marcha para ver el componente fuera de la cadena de esta aplicación. Un contrato inteligente por sí solo no constituye una dapp, ¡estén atentos para ver por qué!
Si quieres algo de experiencia práctica con este código, ¡prueba algunos de los ejercicios! En algunos lugares del código del contrato inteligente, incluí un comentario etiquetado como EXERCISE (EJERCICIO). Por ejemplo, en la definición de tipos menciono el hecho de que un estado de usuario Blocked está disponible, pero actualmente no hay implementada ninguna forma de bloquear a alguien. Agregar esta funcionalidad para bloquear a otro usuario es un ejercicio sugerido y bueno para comenzar. Todos los ejercicios son sugerencias de formas de ampliar la funcionalidad del contrato, lo que te brinda la oportunidad de intentar escribir un código de contrato inteligente por ti mismo. Quizás en una publicación futura de esta serie discutiré algunas soluciones a los ejercicios.
Si estás disfrutando de esta serie de publicaciones de blog, comunícate con nosotros en la consultoría Type-Driven. Nos complace brindar servicios de desarrollo de software para dapps, así como materiales de capacitación para tus propios ingenieros.