Ceci est la deuxième partie d’une série d’articles sur la création d’une application de tchat avec Rust sur la blockchain Near. Vous pouvez trouver le premier article de la série ici.
Dans cet article, nous nous concentrerons sur le contrat intelligent lui-même. Nous verrons la bibliothèque near-sdk qui fait fonctionner notre code sur Near. Nous verrons également les modèles d’accès et les principes de développement de contrats intelligents en examinant le code de ce contrat intelligent. Vous pouvez trouver le référentiel complet avec tout le code dont nous parlerons aujourd’hui sur mon GitHub
Contrat intelligent de Near Rust SDK
À la base, l’environnement d’exécution des contrats intelligents de Near utilise WebAssembly (Wasm). Wasm est un format de bytecode bien établi qui est également utilisé en dehors de la blockchain, comme dans les applications Web. C’est bien pour Near car son exécution peut bénéficier du travail effectué dans la communauté Wasm au sens large.
Le compilateur Rust fait du bon travail pour générer la sortie Wasm, mais il doit y avoir un échafaudage autour de lui pour que le bytecode Wasm fonctionne correctement avec son “hôte” (le runtime Near dans notre cas, ou le moteur JavaScript d’un navigateur Web dans le cas d’une application Web). Cet échafaudage peut être généré automatiquement à l’aide de bibliothèques Rust pratiques : wasm-bindgen dans le cas de l’intégration du navigateur et near-sdk dans le cas de Near. Le contrat intelligent avec lequel nous travaillons aujourd’hui est écrit en quasi-sdk.
Les deux bibliothèques utilisent des macros procedurale Rust (macros proc). Il s’agit d’une sorte de métaprogrammation où la bibliothèque définit de petites annotations que nous pouvons utiliser pour déclencher la génération automatique de code Rust pour nous. Les macros proc de Rust sont utilisées pour réduire la quantité de code passe-partout que le développeur doit écrire pour faire fonctionner sa logique métier. Par exemple, la macro de dérivation proc est au cœur du langage Rust. Il peut définir automatiquement des fonctionnalités communes sur les nouveaux types de données que vous créez. Vous pouvez voir ceci utilisé dans le code snippet suivant du contrat intelligent de chat :
#[derive( Debug, BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, )] #[serde(crate = “near_sdk::serde”)] pub enum MessageStatus { Read, Unread, }
Vous pouvez voir de nombreux traits répertoriés dans l’annotation de dérivation. Pour en appeler certains spécifiques : Debug signifie que le type MessageStatus peut être converti en une chaîne pour aider au débogage du code ; Clone signifie qu’il est possible de créer une instance MessageStatus identique à partir de l’instance actuelle, et Copy signifie que l’opération Clone est bon marché ; PartialEq et Eq signifient que vous pouvez comparer deux instances de MessageStatus pour voir si elles sont identiques. Les traits Serialize et Deserialize proviennent de la bibliothèque serde, qui est omniprésente dans l’écosystème Rust pour encoder/décoder des données à partir de formats tels que JSON ou CBOR. Nous reviendrons sur les traits de Borsh plus tard.
Jusqu’à présent, il s’agissait de Rust standard que vous trouverez dans n’importe quel projet. La macro proc spécifique à Near est near_bindgen que vous pouvez voir utilisée dans le code snippet suivant :
#[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 proc near_bindgen génère automatiquement le code supplémentaire dont nous avons besoin pour que, lorsque nous compilons vers Wasm, nous obtenions une sortie que le runtime Near comprend comment utiliser. Il est utilisé à plusieurs endroits où un tel code de colle est nécessaire. Ici, il marque la structure MessengerContract comme ayant l’état nécessaire pour exécuter les méthodes du contrat. Une instance de la structure MessengerContract sera créée chaque fois que nous appellerons une méthode sur notre contrat intelligent. Nous verrons à quoi servent certains de ces champs plus tard.
La macro near_bindgen est également utilisée sur le bloc impl pour la structure MessengerContract :
#[near_bindgen] impl MessengerContract { // … }
Ici, cela signifie que les fonctions définies dans ce bloc sont les méthodes que nous voulons exposer sur notre contrat intelligent. Il permet aux utilisateurs de la blockchain Near de soumettre des transactions en appelant ces fonctions par leur nom. Par exemple, la méthode d’envoi d’un message est définie dans ce bloc. Nous examinerons plus en détail ci-dessous d’autres méthodes de ce bloc.
En résumé, la bibliothèque de rouille near-sdk fournit une macro proc appelée near_bindgen pour générer automatiquement du code de colle qui fait fonctionner la sortie Wasm avec le runtime Near. Cette macro peut être utilisée sur une structure pour définir l’état de votre contrat et sur le bloc impl de cette structure pour définir les méthodes publiques de votre contrat. Near-sdk fournit également d’autres fonctions et structures utiles, que nous verrons dans les sections suivantes.
État du contrat intelligent
Essentiellement, tous les contrats intelligents non triviaux nécessitent un état pour fonctionner correctement. Par exemple, un contrat de jeton doit maintenir les soldes des différents détenteurs de jetons. Notre contrat de chat n’est pas différent. Nous avons vu dans la section précédente que la structure MessengerContract contenait de nombreux champs. Dans cette section, nous discutons de certaines caractéristiques générales de l’état dans l’environnement d’exécution de Near ainsi que de certaines spécificités quant à la façon dont il est utilisé dans l’exemple de contrat intelligent.
La chose la plus importante à savoir sur l’état du contrat intelligent dans Near est qu’il s’agit d’un simple stockage clé-valeur. Vous pouvez le voir dans les fonctions de bas niveau storage_read et storage_write exposées par near-sdk. Cependant, vous pouvez créer des structures de données plus sophistiquées sur cette base simple, et near-sdk en fournit certaines dans son module de collections. Pour cette raison, notre exemple de contrat n’utilise pas directement le stockage clé-valeur ; à la place, il utilise les collections de niveau supérieur offertes par quasi-sdk.
Par exemple, le contrat intelligent garde une trace de l’état des comptes dont il a connaissance (quels sont des contrats, auxquels nous avons envoyé une demande de contact, etc.). Le champ comptes de MessengerContract est une structure LookupMap de near-sdk. C’est assez proche de l’utilisation directe du stockage clé-valeur puisque la carte est aussi simplement un moyen de rechercher une valeur à partir d’une clé, mais LookupMap fait deux choses importantes au-dessus de l’interface de stockage clé-valeur brute. Premièrement, il a un préfixe qu’il inclut sur toutes les clés de stockage liées à cette carte. L’utilisation d’un préfixe évite de mélanger les clés de cette carte avec les clés d’une autre (par exemple la carte last_received_message, qui est également indexée sur AccountId). Deuxièmement, LookupMap nous permet de travailler avec des types Rust de niveau supérieur alors que l’interface de stockage brut ne fonctionne qu’avec des octets. Ceci est réalisé en utilisant la sérialisation Borsh pour convertir les types vers/depuis les chaînes binaires. Borsh est un format de sérialisation conçu par Near pour être spécifiquement utile dans les applications blockchain. Cette utilisation de Borsh est la raison pour laquelle vous voyez BorshDeserialize et BorshSerialize dérivés sur de nombreux types dans le code.
Un exemple plus intéressant d’une collection utilisée ici est le UnorderedSet utilisé dans le champ unread_messages. Ceci est utilisé pour le contrat afin de garder une trace des messages qui ne sont toujours pas lus. Le UnorderedSet est toujours construit sur le stockage clé-valeur sous-jacent, mais il n’utilise effectivement que les clés car nous ne nous soucions que de savoir si un élément est dans l’ensemble ou non. La structure conserve également des métadonnées sur les clés qu’elle utilise pour nous permettre d’itérer sur toutes les clés de l’ensemble.
Vérifier l’environnement et appeler d’autres contrats
Dans cette section, nous discutons des fonctionnalités générales de l’environnement d’exécution de Near et des appels de contrats croisés. Pour nous garder ancrés, cela se fait dans le contexte de la façon dont les utilisateurs s’ajoutent en tant que contacts dans notre application de chat. Examinons la définition de la fonction add_contact (cette définition se trouve dans le bloc MessengerContact impl, avec l’annotation near_bindgen mentionnée ci-dessus, car c’est un point d’entrée principal pour notre contrat).
#[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)) }
Il y a beaucoup à déballer dans ces quelques lignes de code. Comme cadre supplémentaire pour guider notre discussion, rappelons les trois principes du développement de contrats intelligents décrits dans le post précédent :
1- un état d’esprit contradictoire,
2- économie,
3- s’assurer des invariants avant de faire des appels croisés.
Revenez en arrière et passez en revue le premier article si vous avez besoin d’un rappel sur la nature de ces principes. Chacun de ces principes apparaît dans cette fonction.
Un état d’esprit contradictoire
Toutes les méthodes de contrat intelligent sont publiques et nous devons appliquer le contrôle d’accès lorsque la méthode effectue une action sensible, sinon quelqu’un abusera de la fonctionnalité. Dans ce cas, nous ne voulons pas que quiconque puisse ajouter des contacts au nom du propriétaire ; seul le propriétaire devrait pouvoir décider avec qui se connecter (si quelqu’un d’autre veut établir des contacts dans le réseau de chat, il peut déployer ce contrat sur son propre compte !). Par conséquent, nous avons l’appel require_owner_only() juste en haut du corps de la fonction. L’implémentation de cette fonction est 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 }
Il utilise la fonction predecessor_account_id du module env de near-sdk. Les modules env contiennent de nombreuses fonctions utiles pour interroger les aspects de l’environnement d’exécution Near dans lequel notre contrat s’exécute. Par exemple, nous vérifions ici quel compte a appelé notre contrat. Le module env contient d’autres fonctions utiles telles que la vérification de l’ID de compte de notre contrat lui-même et le nombre de jetons Near attachés à cet appel. Je recommande de lire la documentation du module pour voir toutes les fonctions qui sont disponibles.
Pour des raisons d’efficacité, la fonction require_owner_only renvoie également le compte prédécesseur (pour éviter plusieurs appels à env::predecessor_account_id() au cas où une fonction propriétaire uniquement aurait également besoin du compte prédécesseur pour une autre raison).
Économie
La toute première ligne de l’extrait de code add_contact ci-dessus inclut l’attribut payable. L’utilisation de cette annotation est activée par la fonction définie dans le cadre d’un bloc impl near_bindgen. Cela signifie que cette méthode acceptera les jetons Near des utilisateurs qui l’appellent. Ces jetons sont nécessaires car nous avons décidé que les utilisateurs paient pour des actions telles que la création d’un état en chaîne. Étant donné que l’ajout d’un autre compte en tant que contact crée un état dans leur contrat ainsi que dans le nôtre (nous devons leur faire savoir que nous voulons nous connecter), nous devons nous assurer que l’utilisateur qui initie cette connexion paie pour ce stockage. La caution attachée à cette fonction payante sert à couvrir ce coût de stockage.
Vous pouvez voir quelques lignes plus bas où nous vérifions que la caution est bien présente. Cela utilise la fonction attachment_deposit du module env. Le fait que nous fassions cette vérification tôt correspond parfaitement au troisième principe
S’assurer des invariants avant de faire des appels croisés
La signature de type de la fonction add_contact est importante à noter. Tout d’abord, les arguments de la fonction (&mut self, account: AccountId) signifient qu’il s’agit d’un appel mutable (il changera l’état du contrat) et qu’il prend un argument appelé “account” qui doit être un ID de compte proche. Lorsque near_bindgen fait sa magie, cela signifie que les utilisateurs de la blockchain Near peuvent appeler cette fonction en effectuant une transaction qui prend un argument encodé JSON comme { “account”: “my.account.near” }. Deuxièmement, le type de retour est Promise, ce qui signifie que nous effectuons un appel de contrat croisé à la fin de cette fonction. Les appels croisés sur Near sont asynchrones et non atomiques, nous devons donc nous assurer que tout est en bon état avant de passer l’appel. C’est pourquoi nous incluons le propriétaire uniquement et le chèque de caution en premier dans le corps de la fonction. La nature asynchrone des appels de contrat croisé signifie également qu’il n’y a pas de valeur de retour immédiate de cette fonction. L’appel asynchrone sera effectué et le résultat ne viendra que plus tard, après cet appel.
Vous pouvez voir les détails de l’appel de contrat croisé au bas de la fonction. Il utilise l’API de haut niveau de near-sdk (bien que l’API de bas niveau soit également disponible dans le module env) où la fonction ext est automatiquement générée par near_bindgen et renvoie une structure de données pour construire l’appel de contrat croisé. Vous pouvez voir d’abord que nous utilisons ext(account) pour appeler le compte que nous voulons ajouter en tant que contact. L’appel inclut notre dépôt via with_attached_deposit et appelle la fonction ext_add_contact (qui est définie dans le même bloc impl dans ce cas, mais en général, elle pourrait être définie n’importe où). Enfin, nous appelons then ce qui signifie inclure un callback. Le rappel est lui-même une autre promesse, nous utilisons donc à nouveau la même fonction ext, mais cette fois en appelant notre propre ID de compte. Ceci est fait pour que notre contrat puisse savoir quelle était la réponse du contrat que nous essayons d’ajouter en tant que contact. Je n’entrerai pas dans les détails des implémentations ext_add_contact ou add_contact_callback ici (ils manipulent simplement le stockage en fonction de l’état actuel du compte), mais je vous encourage à les lire dans le code source sur GitHub si vous êtes intéressé.
Résumé
Dans cet article, nous avons plongé la tête la première dans du code ! Nous avons vu comment near_bindgen est utilisé pour générer automatiquement le code nécessaire à l’exécution de notre contrat dans l’environnement d’exécution Near, ainsi que d’autres fonctionnalités de near-sdk pour interagir avec le stockage, l’environnement d’exécution et d’autres contrats. Dans le prochain article, nous continuerons à plonger dans le code, mais changeons de vitesse pour examiner le composant hors chaîne de cette application. Un contrat intelligent ne constitue pas à lui seul une dapp, restez à l’écoute pour savoir pourquoi !
Si vous voulez une expérience pratique avec ce code, essayez quelques-uns des exercices ! À quelques endroits dans le code du contrat intelligent, j’ai inclus un commentaire marqué comme EXERCICE. Par exemple, dans la définition des types, j’indique le fait qu’un statut d’utilisateur bloqué est disponible, mais il n’y a aucun moyen de bloquer quelqu’un actuellement implémenté. L’ajout de cette fonctionnalité pour bloquer un autre utilisateur est un exercice suggéré, et un bon pour commencer. Tous les exercices sont des suggestions de moyens d’étendre les fonctionnalités du contrat, vous donnant l’occasion d’essayer d’écrire vous-même un code de contrat intelligent. Peut-être que dans un prochain article de cette série, je discuterai de quelques solutions aux exercices.
Si vous appréciez cette série d’articles de blog, veuillez nous contacter à Type-Driven Consulting. Nous sommes heureux de fournir des services de développement de logiciels pour les dapps, ainsi que du matériel de formation pour vos propres ingénieurs.