DEV Community

Cover image for French Solana dev #1: Développer un program (smart-contract) sur la blockchain Solana avec le framework Anchor ⚓️🧑‍💻
Loïs Lagoutte
Loïs Lagoutte

Posted on

French Solana dev #1: Développer un program (smart-contract) sur la blockchain Solana avec le framework Anchor ⚓️🧑‍💻

Préambule

Assez récemment, je me suis rendu compte du manque de ressources écrites en français dans le domaine du développement et du web3.

Même si de plus en plus de ressources commencent à voir le jour pour l’écriture de smart-contracts avec le populaire Solidity, il n’en est pas de même pour les blockchains n’étant pas EVM-compatible.

En effet, ces blockchains utilisent toutes des architectures différentes, et n’étant pas aussi populaire qu’Ethereum à l’heure où j’écris ces lignes, il est plus compliqué de mettre la main sur de bonnes ressources d’apprentissage dans sa langue d’origine.

Alors si vous êtes allergiques à l’anglais, je vous invite à lire cet article qui, j’espère, pourra vous accompagner et vous aider à comprendre le fonctionnement d’un program sur Solana 🙂


Pré-requis

Nous utiliserons le langage Rust pour écrire notre program, je vous conseille donc d’avoir déjà les bases du langage. The Rust Book est la ressource de référence pour se familiariser avec Rust (aussi disponible "partiellement" en français !).

Il est évidemment primordial que vous soyez déjà familier avec l’architecture d’une blockchain ainsi que son fonctionnement.

Vous devrez aussi lire la documentation developer de Solana pour bien comprendre de ce que l’on parle, même si je reviendrai sur certains points pour essayer d’apporter une vision plus vulgarisé.


📦 Installations

Rust

Vous pouvez installer Rust à l'aide de ces trois commandes:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
rustup component add rustfmt
Enter fullscreen mode Exit fullscreen mode

Pour une installation plus détaillée, référez vous au Rust Book.

Solana

Le client solana vous permet d’interagir avec les différents réseaux Solana, de générer et gérer vos différents comptes (accounts) et divers autres utilités...

Sur macOS et Linux:

sh -c "$(curl -sSfL https://release.solana.com/v1.10.4/install)"
Enter fullscreen mode Exit fullscreen mode

L’installation détaillée est disponible sur la documentation de Solana.

Pour la suite, vous aurez besoin d’un account pour l’utiliser avec anchor.
🔑 solana-keygen nous permet de générer une paire de clés publique/privée:

solana-keygen new
Enter fullscreen mode Exit fullscreen mode

Yarn

Yarn est utilisé par Anchor. Si vous ne l’avez pas sur votre machine, il est possible de l’installer via NPM:

npm i -g corepack
Enter fullscreen mode Exit fullscreen mode

Anchor

Anchor est un framework simplifiant énormément la vie des développeurs sur Solana et contenant une panoplie de features telles que:

  • Des crates et une librairie Rust
  • Une IDL complète pour nos programs
  • Un package TypeScript pour utiliser nos programs avec l’IDL
  • une CLI et un gestionnaire d’espace de travail pour développer des applications du backend au frontend

Il ne s’agit ici que de créer notre program. Les testes et l’interaction avec notre program déployé en front étant plus propice à une future partie.

Anchor est comparable à Truffle ou bien Hardhat, les deux frameworks les plus utilisés pour travailler sur des smart-contracts en Solidity.

Pour installer anchor sur votre machine, il est préférable d’utiliser le gestionnaire de version d’anchor (AVM).

Celui-ci est à installer via cargo:

cargo install --git https://github.com/project-serum/anchor avm --locked --force
Enter fullscreen mode Exit fullscreen mode

Vous pouvez ensuite installer la dernière version d’anchor via l’avm:

avm install latest
avm use latest
Enter fullscreen mode Exit fullscreen mode

Pour vérifier qu’anchor est bien installé:

anchor --version
Enter fullscreen mode Exit fullscreen mode

D’autres méthodes d’installation sont disponibles sur l’anchor book.


Création du projet

Pour créer un nouveau projet anchor, il suffit d’utiliser la commande suivante:

anchor init <nom-du-projet>
Enter fullscreen mode Exit fullscreen mode

Cela crée un dossier avec le nom de votre projet passé en argument et une base de projet à partir de laquelle vous pouvez commencer à travailler.


Structure d’un projet Anchor

Il est important de comprendre les fichiers et dossiers que compose un projet Anchor:

  • Le dossier .anchor contient un réseau local ainsi que divers logs liés à celui-ci.
  • Le dossier app peut accueillir le front-end lié à vos programs si vous désirer travailler dans un seul repository.
  • Le dossier migrations contient nos scripts de migrations et de déploiement.
  • Le dossier programs contient tout nos programs. En effet, on peut écrire de multiples programs pour notre projet. Notez qu’anchor a déjà créé un program avec le nom de votre projet, qui contient un code minimaliste d’exemple dans lib.rs.
  • Le dossier target est typique à Rust et contient tous les builds et les fichiers compilés. Pas besoin de toucher à ce dossier dans 99% des cas.
  • Le dossier tests contient tout nos scripts écrits pour tester nos programs.

A la racine se situe aussi le fichier de configuration d’anchor Anchor.toml contenant une configuration de base:

  • [programs.localnet] contient les IDs de nos différents programs, nous y reviendront juste après 🙂
  • [registry] vous permet de push votre projet vers un registre de programs.
  • [provider] contient le réseau à utiliser pour exécuter vos scripts de tests ainsi que l’account à utiliser.
  • [scripts] contient la commande que anchor test exécute pour vos scripts de tests

Structure d’un program

Un program Solana écrit avec Anchor se compose en plusieurs parties distinctes:

  • Une macro declare_id! qui définit l’ID de notre program.
  • Un module comportant l’attribut #[program] où est définit tous les points d’entrées (entrypoints) de notre program. Une entrypoint est une fonction que l’on peut appeler de l’extérieur, dans une transaction, par exemple par RPC. On appelle plus fréquemment ces fonctions des Insctructions et elles modifient l’état de la blockchain.
  • Des structures implémentant l’attribut #[derive(Accounts)] qui définissent tous les accounts dont une instruction a besoin. Ces structures sont alors passées dans le Context de nos instructions.
  • Des structures implémentant l’attribut #[account] vous permettent de stocker des données dans votre program. Je rappelle que toutes les données sont stockées sous la forme d’un account et donc que chaque données ont au moins une clé publique. Nous y reviendront par la suite.

Pour la suite, on travaillera sur la base d’un program ayant pour but de définir une identité pour chaque utilisateur. Un utilisateur pourra créer son identité, et ensuite modifier certaines parties de son identité. Il pourra aussi supprimer son identité de la blockchain, mais seulement 2 ans après sa création !


Analyse du program d’exemple

Après avoir créé votre projet anchor, vous pouvez vous rendre dans votre premier program qui a été généré avec le nom de votre projet dans le chemin suivant:

/programs/<nom-du-projet>/src/lib.rs

lib.rs comporte déjà un code d’exemple minime qui devrait ressembler au code suivant:

use anchor_lang::prelude::*;

/// Notre macro de déclaration d'ID pour notre program
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");

/// Notre module définissant les différentes instructions de notre program
#[program]
pub mod identity {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }
}

/// Une structure comportant les accounts à passer dans le Context de notre instruction
#[derive(Accounts)]
pub struct Initialize {}
Enter fullscreen mode Exit fullscreen mode

Comme vous pouvez le remarquer, nous avons trois des quatre parties que je vous ai présentés juste au dessus. Sachant qu’un program n’est pas obligé de stocker des données, nous n’avons pas de structures implémentant l’attribut #[account] pour le moment.

Ce program d’exemple comporte un point d’entrée, l’instruction initialize().
Toutes les instructions reçoivent en paramètres au moins un Context<T> contenant des données sur le contexte actuel. T étant une structure comportant l’attribut #[derive(Accounts)].

Une instruction retourne un Result<T, Error>. Result est un élément typique de Rust, qui peut comporter dans notre cas un Ok<T>, qui est renvoyé si notre instruction réussit, ou sinon un Err(Error) en cas d’échec. Ici, Error est le type d’erreur que Anchor fournit. (Nous verrons comment faire nos propres erreurs plus tard).

Cette instruction ne fait donc rien hormis renvoyer un Ok(()) pour signaler la réussite de notre instruction (heureusement vu qu’elle ne fait rien...)


Modification de l’ID de notre program

Quand anchor génère ce program, l’ID fournit au program est toujours le même:

declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS")
Enter fullscreen mode Exit fullscreen mode

Bien que pour le moment, cela ne dérange pas, mieux vaut que notre program soit unique et donc, que l’ID soit aussi unique !

Pour ce faire, nous allons modifier cet ID par la clé publique de l’account généré pour notre program.

En effet, anchor nous génère déjà un account avec sa paire de clé publique/privé pour chaque program que l’on crée.
Ces accounts sont accessibles en tapant la commande suivante:

anchor keys list
Enter fullscreen mode Exit fullscreen mode

Vous devriez voir apparaitre votre program ainsi que sa clé publique.

❯ anchor keys list
identity: GxyJLSDuC7BkeorKoMg87uhaXvuaUxDjDKT7iQWCbxXJ
Enter fullscreen mode Exit fullscreen mode

Je pense que vous l’aviez deviné, on va remplacer l’ID par défaut de notre program par la clé publique de l’account généré pour notre program.

declare_id!("GxyJLSDuC7BkeorKoMg87uhaXvuaUxDjDKT7iQWCbxXJ");
Enter fullscreen mode Exit fullscreen mode

Il faut aussi remplacer l’ID par défaut dans le fichier de configuration d’anchor, Anchor.toml à la racine de notre projet:

[programs.localnet]
identity = "GxyJLSDuC7BkeorKoMg87uhaXvuaUxDjDKT7iQWCbxXJ"
Enter fullscreen mode Exit fullscreen mode

Une fois cela fait, on peut commencer de rentrer dans le vif du sujet 😉


Définition d’une identité

Avant de commencer à travailler sur nos différentes instructions et nos différents contexts, il est plus cohérent de définir notre structure Identity qui définira comment une identité est stocké par notre program.

Etant assez maniaque sur l’organisation du code, je conseille de séparer la définition de notre identité dans un fichier différent. Pour ma part, ce sera dans un module que j’ai nommé identites.rs.

Pour importer notre nouveau module dans notre program et pouvoir utiliser son contenu:

mod identites;
Enter fullscreen mode Exit fullscreen mode

Si vous vous rappelez bien, on définit une structure stockant des données avec l’attribut #[account]:

use anchor_lang::prelude::*;

/// Définit la structure d'une identité d'un utilisateur
#[account]
pub struct Identity {}
Enter fullscreen mode Exit fullscreen mode

On souhaite qu’une identité contienne un prénom, un nom, un pseudonyme, sa date de naissance, sa date de création et l’utilisateur peut aussi spécifier une adresse mail sans être obligatoire.

Notre structure se définit donc comme suit:

/// Définit la structure d'une identité d'un utilisateur
#[account]
pub struct Identity {
    pub first_name: String, // max 128 bytes
    pub last_name: String,  // max 128 bytes
    pub username: String,   // max 128 bytes
    pub birth: i64,
    pub mail: Option<String>, // max 128 bytes
    pub created: i64
}
Enter fullscreen mode Exit fullscreen mode

Plusieurs remarques:

  • Les champs contenant une chaine de caractères doivent avoir une taille maximum prédéfinie, bien que le type String de Rust ne soit pas forcément limité, c’est à nous de définir un maximum, nous verrons pourquoi juste après ! Dans notre cas, sachant qu’un caractère encodé en UTF-8 peut mesurer 1 à 4 bytes, 128 bytes nous assure 32 caractères dans le pire des cas.
  • Les champs birth et created contiennent une date sous la forme d’un Unix timestamp. Pour une optimisation de taille, i32 serait préférable à i64, mais i32 est dangereux et pourrait rendre le program inutilisable le jour où l’unix timestamp dépasse la limite de taille d’un i32 (en l’an 2038...). i64 nous assure une utilisation sans problème jusqu’en l’an 2262 😃 !
  • Notre champ mail étant optionnel, le typique Option<T> de Rust est le plus adapté ici.

Il manque un point important à aborder pour nos accounts de données... l’espace fixé !


La taille de notre structure identité

Si vous vous rappelez, chaque account sur la blockchain Solana est initialisé avec une taille maximum prédéfinie, ce qui permet à la blockchain de savoir à combien s’élève le montant à devoir payer où à garder sur l’account pour être “rent-exempt”.

❗Même si vous n’utilisez pas tout l’espace disponible sur un account, vous payez quand même pour le maximum ! Il faut donc faire attention à régler une taille maximum cohérente sur vos accounts de données pour ne pas faire fuir vos utilisateurs...

Il faut déterminer la taille maximum que peut prendre une identité, et pour cela, il faut se référencer au tableau suivant disponible sur le Anchor Book:

spaces array

Par rapport à notre structure Identity, cela nous donne:

/// Définit la structure d'une identité d'un utilisateur
#[account]
pub struct Identity {
    pub first_name: String, // 128 + 4 = 132
    pub last_name: String,  // 128 + 4 = 132
    pub username: String,   // 128 + 4 = 132
    pub birth: i64, // 8
    pub mail: Option<String>, // 128 + 1 = 129
    pub created: i64 // 8
}
Enter fullscreen mode Exit fullscreen mode

Pour finir, on peut placer ces données dans des constantes de notre structure Identity:

impl Identity {
    pub const MAX_STRING_SIZE: usize = 128;
    pub const MAX_IDENTITY_SIZE: usize = 132 + 132 + 132 + 8 + 129 + 8;
}
Enter fullscreen mode Exit fullscreen mode

Définition de nos entrypoints

On va ensuite définir nos différentes instructions pour gérer notre identité.

On peut supprimer l’instruction initialize() d’exemple car nous ne l’utiliserons pas.

En reprenant le cahier des charges que j’ai énoncé plus haut, j’ai défini toutes ces instructions:

#[program]
pub mod identity {
    use super::*;

    /// Permet à un utilisateur sans identité de créer son identité
    pub fn create_identity(
        ctx: Context<Initialize>,
        first_name: String,
        last_name: String,
        username: String,
        birth: i64,
        mail: Option<String>
    ) -> Result<()> {
        // TODO
        Ok(())
    }

    /// Permet à un utilisateur de mettre à jour son prénom
    pub fn update_name(ctx: Context<Initialize>, first_name: String) -> Result<()> {
        // TODO
        Ok(())
    }

    /// Permet à un utilisateur de mettre à jour son pseudonyme
    pub fn update_username(ctx: Context<Initialize>, username: String) -> Result<()> {
        // TODO
        Ok(())
    }

    /// Permet à un utilisateur de mettre à jour ou supprimer son mail
    pub fn update_mail(ctx: Context<Initialize>, mail: Option<String>) -> Result<()> {
        // TODO
        Ok(())
    }

        /// Permet à un utilisateur ayant une identité depuis plus de 2 ans
        /// de supprimer son identité 
        pub fn delete_identity(ctx: Context<Initialize>) -> Result<()> {
        // TODO
        Ok(())
    }
}
Enter fullscreen mode Exit fullscreen mode

Nos instructions sont assez explicites je pense, pas besoin de revenir dessus.

Il faut maintenant définir nos différents Accounts à passer à nos instructions...


Définition des struct Accounts à passer à notre instructions

Bien que cela puisse paraître surprenant, nous auront besoin de définir seulement trois structures Accounts différentes !

3 structures pour 5 instructions ? Eh oui !

C’est parce que nos instructions update utiliseront toutes la même logique 🙂

Commençons par définir les Accounts que notre instruction create_identity() à besoin.

Pour rappel, on définit une structure Accounts avec l’attribut #[derive(Accounts)], comme tel:

#[derive(Accounts)]
pub struct CreateIdentity<'info> {}
Enter fullscreen mode Exit fullscreen mode

Puis on ajoute pour chaque nouveau champ, le type d’account qui est attendu.

Il existe plusieurs types que vous pouvez renseigner, en voici une liste non-exhaustive:

  • Le type Account<’info, T>, qui assure que T est une donnée dont notre program est propriétaire (Par exemple: notre structure Identity)
  • Le type Signer<’info>, qui assure que l’account spécifié à bien signer la transaction.
  • Le type Program<’info, T>, qui assure que l’account spécifié est bien un program TT est l’ID du program voulu.
  • Le type UncheckedAccount<’info>, qui ne procède à aucune vérification sur l’account spécifié.

En plus de ces différents types, il est possible d’utiliser des contraintes (constraints) sur nos accounts pour procéder à d’autres vérifications. Il est possible d’ajouter ces contraintes en ajoutant un attribut #[account()] au dessus du champ de l’account, en ajoutant dans les parenthèses les paramètres voulus.

En voici une list non-exhaustive (Plus de détails ici) :

  • #[account(mut)] rend l’account mutable et permet de modifier son état (Par exemple: lui faire dépenser des SOL).
  • #[account(address = <expr>)] vérifie que la clé publique de l’account correspond à expr.
  • #[account(init,payer = <target_account>,space = <bytes_size>] permet d’initialiser l’account spécifié. Un payer doit être spécifié pour régler le montant requis lors du stockage des données, ainsi que l’espace maximum que la donnée prend. Nous l’utiliserons juste après pour créer notre identité 🙂

Il en existe encore un autre d’assez important, mais je ne vais pas vous embêter pour le moment avec ça, on y viendra assez vite dans tout les cas ! Voyez ça comme le boss final du développement de program sur Solana 😃

Nous avons maintenant toutes les clés en mains pour implémenter notre structure CreateIdentity.

Pour résumer, nous avons besoin du compte de l’utilisateur, qui devra signer la transaction pour créer son identité, ainsi que payer la création de ses données. Nous avons aussi besoin du System Program pour créer notre account Identity qui stockera l’identité de notre utilisateur.

Voici à quoi ressemble notre Context CreateIdentity:

#[derive(Accounts)]
pub struct CreateIdentity<'info> {
    #[account(mut)]
    pub user: Signer<'info>,
    #[account(init, payer = user, space = Identity::MAX_IDENTITY_SIZE + 8)]
    pub identity: Account<'info, Identity>,
    pub system_program: SystemAccount<'info>
}
Enter fullscreen mode Exit fullscreen mode

Remarquez que l’on rend l’account de l’utilisateur mutable, requis par le system program pour créer l’account identity et payer la rent de notre account identity.
De plus, vous vous demandez peut-être quel est ce “+ 8” pour le paramètre space ?
Je ne vais pas rentrer dans des détails trop technique, mais ce sont 8 bytes requis par anchor lors de la déserialisation de notre account Identity.


Définition des autres structures Accounts

Pour les instructions update_name(), update_username() et update_mail(), nous avons besoin du compte de l’utilisateur, de sa signature, de son account identity, et c’est tout !

Notez que l’account identity doit être mutable vu qu'on va modifier ses données 🙂

#[derive(Accounts)]
pub struct UpdateIdentity<'info> {
    pub user: Signer<'info>,
    #[account(mut)]
    pub identity: Account<'info, Identity>
}
Enter fullscreen mode Exit fullscreen mode

Peut-être que vous avez déjà remarqué que quelque chose n’allait pas... nous y reviendront après ;)
Pour les plus malins, faites comme si tout allait bien et continuons avec l’implémentation de la logique de nos instructions.


Implémentation de nos instructions

create_identity

Commençons par écrire la première instruction qu’un utilisateur doit appeler, ce qui va initialiser son account Identity et stocker son identité.

Pas besoin de gérer l’initialisation de l’account Identity car celle-ci est effectuée avec notre contraintes init comme vu plus haut (une bonne chose de faite ! 🙂).

Il faut d’abords vérifier que les String fournis par l’utilisateur ne dépasses pas la limite fixé, c’est à dire 128 bytes comme définit plus tôt. Si vous êtes familier avec Solidity, anchor propose des macros require pour retourner vérifier une condition entre deux variables et retourner une erreur au choix.

❗On pourrait (et est recommandé) faire d’autres vérifications pour optimiser la sécurité de notre program, mais je vais faire l’impasse histoire d’alléger pour le moment.

// Check des infos fournit par l'utilisateur
require_gte!(Identity::MAX_STRING_SIZE, first_name.len());
require_gte!(Identity::MAX_STRING_SIZE, last_name.len());
require_gte!(Identity::MAX_STRING_SIZE, username.len());
if mail.is_some() {
    require_gte!(Identity::MAX_STRING_SIZE, mail.as_ref().unwrap().len());
}
Enter fullscreen mode Exit fullscreen mode

Il ne reste plus qu’à enregistrer ces données dans notre tout nouvel account Identity.

Pour cela, rien de plus simple, on passe juste les valeurs fournit lors du call de l'instruction par l’utilisateur pour chaque champs de notre account identity.

On peux accéder aux données et infos des différents Accounts que l’on a passé à notre Context avec ctx.accounts.

// Enregistrement des données dans notre account Identity
let user_identity = &mut ctx.accounts.identity;
user_identity.first_name = first_name;
user_identity.last_name = last_name;
user_identity.birth = birth;
user_identity.mail = mail;
user_identity.created = Clock::get().unwrap().unix_timestamp;
Enter fullscreen mode Exit fullscreen mode
/// Permet à un utilisateur sans identité de créer son identité
    pub fn create_identity(
        ctx: Context<CreateIdentity>,
        first_name: String,
        last_name: String,
        username: String,
        birth: i64,
        mail: Option<String>
    ) -> Result<()> {
        // Check des infos fournit par l'utilisateur
        require_gte!(Identity::MAX_STRING_SIZE, first_name.len());
        require_gte!(Identity::MAX_STRING_SIZE, last_name.len());
        require_gte!(Identity::MAX_STRING_SIZE, username.len());
        if mail.is_some() {
            require_gte!(Identity::MAX_STRING_SIZE, mail.as_ref().unwrap().len());
        }

        // Enregistrement des données dans notre account Identity
        let user_identity = &mut ctx.accounts.identity;
        user_identity.first_name = first_name;
        user_identity.last_name = last_name;
        user_identity.birth = birth;
        user_identity.mail = mail;
        user_identity.created = Clock::get().unwrap().unix_timestamp;

        Ok(())
    }
Enter fullscreen mode Exit fullscreen mode

Et voila ! Notre première instruction est pleinement implémentée et fonctionnelle.... enfin presque.

Vous vous souvenez du boss final dont j’ai rapidement évoqué plus haut ? Il est venu le temps d'y faire face !

Laissez moi vous parler des PDA.


Program Derived Address

Le problème actuel

Pour commencer, analysons le problème qui se pose actuellement sur notre program d’identité.

Je vous rappelle que chaque utilisateur a sa propre identité, c’est à dire que pour chaque utilisateur/clé publique, un account Identity doit être créé et exister.

Premièrement, cela veut dire qu’un utilisateur doit d’abord générer une nouvelle paire de clé publique/privée qui sera son account Identity si il souhaite une identité, ce qui n’est pas très pratique. Cela permettrait en plus d’avoir un nombre infini d’identité pour une seule clé publique.
Ensuite, même si les utilisateurs était d’accord avec ce système, cela comporte un énorme problème de sécurité. Regardons sur nos Accounts que l’on passe pour nos instructions update:

#[derive(Accounts)]
pub struct UpdateIdentity<'info> {
    pub user: Signer<'info>,
    #[account(mut)]
    pub identity: Account<'info, Identity>
}
Enter fullscreen mode Exit fullscreen mode

A quoi sert user ? Eh bien... pour le moment, à rien.
En effet, peu importe qui signe la transaction, et peu importe la clé publique de l’account identity, les modifications seront pris en compte pour l’account identity....

Le problème ici est que nous n’avons aucun lien entre l’utilisateur signant la transaction et l’account identity fourni, et qu’il est impossible de vérifier la propriété et l’unicité d’une identité par rapport à la clé publique d’un utilisateur.

Bon, rassurez-vous, si je vous parle de tout ça, c’est que le PDA résout tout ces problèmes 🙂

Explication des PDA

Avant toute chose, le PDA est l'un des principes les plus fourbes, mais aussi des plus important pour un développeur Solana, ne vous découragez pas maintenant !

Les PDA, pour “Program Derived Address” où bien “Adresse dérivée de programme”, sont des adresses générées à partir de l’ID d’un program et de plusieurs seeds.

Une PDA a une certaine particularité, elle ne doit pas appartenir à la courbe ed25519 !
Ce qui veut dire qu’une PDA a la forme d’une clé publique, MAIS N’A PAS de clé privée associée. Il est donc impossible pour un utilisateur de générer une signature valide pour un account avec une PDA en tant que clé publique !

Le PDA est un remplaçant direct au Mapping qu'on pourrait connaitre sur Solidity pour associer une adresse à une donnée. (Pour ceux qui se posent la question, HashMap de Rust n’est pas fonctionnelle dans un program sur Solana “pour le moment”).

Maintenant, penchons nous sur la fonction suivante:

findProgramDerivedAddress(programId, seeds)
Enter fullscreen mode Exit fullscreen mode

Cette fonction retourne l’adresse trouvée à partir de l’ID du program fournit ainsi qu’une seed fournit. Le problème étant que cette fonction a une chance de réussite d’environ 50%.

Souvenez-vous qu’une PDA valide ne doit pas appartenir à la courbe ed25519, de ce fait, nous devons être certain que notre fonction renvoie une adresse valide.

Pour ce faire, il faut ajouter un troisième argument, qu'on appelle le “bump”. Ce bump est un entier qui sera incrémenté à chaque fois qu’une adresse non-valide est retournée. La fonction va alors se répéter en incrémentant le bump jusqu’à trouver une adresse ne se trouvant pas sur la courbe.

findProgramDerivedAddress(programId, seeds, bump)
Enter fullscreen mode Exit fullscreen mode

Grâce au PDA, nous pouvons désormais créer un account Identity unique pour chaque utilisateur sans devoir stocker quoi que ce soit car cette adresse est calculable directement par notre program ou nos utilisateurs !

Nous pourrons aussi définir des contraintes pour faire en sorte que l’utilisateur qui signe la transaction ne puisse accéder qu’à son propre account Identity et la modifier en conséquence.


Implémentation du PDA

CreateIdentity

Cette implémentation se fait au niveau de nos structures Accounts.

Regardons du coté de nos Accounts que l’on passe pour notre instruction de création d’identité:

#[derive(Accounts)]
pub struct CreateIdentity<'info> {
    #[account(mut)]
    pub user: Signer<'info>,
    #[account(
        init,
        payer = user, 
        space = Identity::MAX_IDENTITY_SIZE + 8,
        // PDA à implémenter
    )]
    pub identity: Account<'info, Identity>,
    pub system_program: SystemAccount<'info>
}
Enter fullscreen mode Exit fullscreen mode

Bien que la notion de PDA puisse être complexe à comprendre, l’implémentation de celle-ci se fait en quelques lignes !

Nous devons ajouter une contrainte seeds lors de l’initialisation de notre account identity qui permet de calculer la PDA à partir de la seeds fournit, et de refuser toute autre adresse passée si l’adresse n’est pas la PDA calculée.

Nous devons maintenant savoir quelle seed utiliser. En règle générale, notre seed sera basée sur au moins trois paramètres:

  • Une chaine de caractères pour pouvoir différencier la génération d’une PDA d’un certain type d’account à d’autres.
  • La clé publique de notre utilisateur signant la transaction. C’est ce qui permet de faire le lien entre son identité et sa clé publique ! En effet, chaque clé publique générera une PDA différente 🙂.
  • Le bump, essentiel pour assurer notre program de trouver une PDA avec nos deux premières seeds fournies. Notre program utilisera le premier bump valide, aussi appelé “bump canonique” (canonical bump)

Retranscrit au niveau de notre code, voici ce que l’on obtient:

seeds = [b"Identity", user.key().as_ref()], bump
Enter fullscreen mode Exit fullscreen mode

Il faut ensuite sauvegarder le bump trouvé par notre program pour s’assurer que l’adresse générée plus tard pour accéder à notre account Identity sera toujours la même.

/// Définit la structure d'une identité d'un utilisateur
#[account]
pub struct Identity {
    pub first_name: String, // 128 + 4 = 132
    pub last_name: String,  // 128 + 4 = 132
    pub username: String,   // 128 + 4 = 132
    pub birth: i64, // 8
    pub mail: Option<String>, // 128 + 1 = 129
    pub created: i64, // 8
    pub bump: u8 // 1
}
Enter fullscreen mode Exit fullscreen mode

!N’oubliez pas de rajouter l’espace que prends le bump dans notre structure Identity sur la constante MAX_IDENTITY_SIZE.

Il ne reste plus qu’à stocker le bump trouvé par notre program lors de la création de notre identité en l’implémentant dans notre instruction. Les bumps calculés par notre program sont accessibles par ctx.bumps.get(<account>).

user_identity.bump = *ctx.bumps.get("identity").unwrap();
Enter fullscreen mode Exit fullscreen mode

UpdateIdentity

L’implémentation est presque identique pour nos Accounts d’update.
Il faut vérifier que l’adresse passée est bien la PDA calculée pour l’utilisateur qui signe la transaction.

#[account(
    mut,
    seeds = [b"Identity", user.key().as_ref()], bump = identity.bump
)]
pub identity: Account<'info, Identity>
Enter fullscreen mode Exit fullscreen mode

CloseIdentity

#[account(
    mut,
    close = user,
    seeds = [b"Identity", user.key().as_ref()], bump = identity.bump
)]
pub identity: Account<'info, Identity>
Enter fullscreen mode Exit fullscreen mode

Et voila ! Notre logique de PDA est bien implémentée et chacun de nos utilisateurs ne peut gérer qu’une seule identité et seulement la leur 🙂

--

Implémentation de nos instructions Part. 2

Implémentons maintenant la logique de nos instructions de modifications.
Cela sera le même modèle pour nos trois instructions:

  1. Vérification de la donnée
  2. Stockage de la donnée

update_name()

// Permet à un utilisateur de mettre à jour son prénom
pub fn update_name(ctx: Context<UpdateIdentity>, first_name: String) -> Result<()> {
    require_gte!(Identity::MAX_STRING_SIZE, first_name.len());

    ctx.accounts.identity.first_name = first_name;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

update_username()

/// Permet à un utilisateur de mettre à jour son pseudonyme
pub fn update_username(ctx: Context<UpdateIdentity>, username: String) -> Result<()> {
    require_gte!(Identity::MAX_STRING_SIZE, username.len());

    ctx.accounts.identity.username = username;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

update_mail()

/// Permet à un utilisateur de mettre à jour ou supprimer son mail
pub fn update_mail(ctx: Context<UpdateIdentity>, mail: Option<String>) -> Result<()> {
    if mail.is_some() {
        require_gte!(Identity::MAX_STRING_SIZE, mail.as_ref().unwrap().len());
    }

    ctx.accounts.identity.mail = mail;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

delete_identity()

L’étape finale consiste à implémenter l’instruction qui permettra à un utilisateur de supprimer son identité si celle-ci existe depuis plus de deux ans.

La fermeture de l’account étant déjà gérée par la contrainte close, il s’agira ici seulement de vérifier que l’identité existe depuis au moins 2 ans, ce sans quoi la transaction sera revert et donc l’account ne sera pas fermé.

/// Permet à un utilisateur ayant une identité depuis plus de 2 ans
/// de supprimer son identité 
pub fn delete_identity(ctx: Context<CloseIdentity>) -> Result<()> {
    let now = Clock::get().unwrap().unix_timestamp;
    let created = ctx.accounts.identity.created;
    let since = now - created;

    require_gt!(since, CAN_DELETE_AFTER);

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Définition et émission d'un événement (Event)

Les Events sont des éléments importants à mettre en place pour garder un historique efficace de certaines données et faciliter la communication avec nos applications off-chain.
Celles-ci pourront souscrire à l'event lié à un certain contexte de notre program, et exécuter une ou des actions en conséquence à chaque nouvel événement émit.

Nous allons mettre en place un event qui sera émit à chaque création d'une nouvelle identité.
Un event se définit par une structure comportant l'attribut #[event] proposé par anchor. Cette structure peut accueillir divers champs qui définiront les données que notre event contiendra.

Dans notre cas, nous souhaitons que notre event contienne la clé publique de l'utilisateur ayant créé son identité, son pseudonyme ainsi que la date et l'heure de la création de l'identité.

#[event]
pub struct IdentityCreated {
    pub pubkey: Pubkey,
    pub username: String,
    pub timestamp: i64,
}
Enter fullscreen mode Exit fullscreen mode

Rien de sorcier ici, il ne nous reste plus qu'à émettre notre événement à la fin de notre instruction.
La macro emit!() fournit par anchor nous permet d'émettre une structure comportant l'attribut #[event] comme suit:

// Emet un `Event` signifiant qu'une nouvelle identité est crée
        emit!(event::IdentityCreated {
            pubkey: ctx.accounts.user.key(),
            username,
            timestamp: ctx.accounts.identity.created
        });
Enter fullscreen mode Exit fullscreen mode

Définition de nos erreurs personnalisées

Pour finaliser notre program, nous pouvons définir et implémenter des erreurs personnalisées avec des messages plus explicites pour nos utilisateurs suivant les différents contextes.

Anchor propose un attribut #[error_codes] qui permet d’implémenter le type Error de anchor à une énumération d’erreurs personnalisées.

Encore une fois, je vais définir les erreurs dans un fichier différent que j’appellerai error.rs

use anchor_lang::error_code;

#[error_code]
pub enum IdentityError {
    StringTooLarge,
    TimeNotPassed
}
Enter fullscreen mode Exit fullscreen mode

Rien de bien compliqué ici 🙂

Pour définir un message personnalisé sur une erreur, nous pouvons utiliser l’attribut #[msg]:

#[msg("Specified string is higher than the expected maximum space")]
StringTooLarge,
#[msg("2 year is needed since the creation of the identity to be closed")]
TimeNotPassed
Enter fullscreen mode Exit fullscreen mode

Il ne reste plus qu’à implémenter nos erreurs personnalisées dans nos instructions !

require_gt!(since, CAN_DELETE_AFTER, IdentityError::TimeNotPassed);
Enter fullscreen mode Exit fullscreen mode

Et voila ! Notre program est désormais terminé, bien qu’encore améliorable, mais ce n’est pas le but de cet article 🙂

mod identites;
mod error;

use anchor_lang::prelude::*;
use identites::Identity;
use error::IdentityError;

declare_id!("GxyJLSDuC7BkeorKoMg87uhaXvuaUxDjDKT7iQWCbxXJ");

#[program]
pub mod identity {
    use super::*;

    pub const CAN_DELETE_AFTER: i64 = 31556926 * 2;

    /// Permet à un utilisateur sans identité de créer son identité
    pub fn create_identity(
        ctx: Context<CreateIdentity>,
        first_name: String,
        last_name: String,
        username: String,
        birth: i64,
        mail: Option<String>
    ) -> Result<()> {
        // Check des infos fournit par l'utilisateur
        require_gte!(Identity::MAX_STRING_SIZE, first_name.len(), IdentityError::StringTooLarge);
        require_gte!(Identity::MAX_STRING_SIZE, last_name.len(), IdentityError::StringTooLarge);
        require_gte!(Identity::MAX_STRING_SIZE, username.len(), IdentityError::StringTooLarge);
        if mail.is_some() {
            require_gte!(Identity::MAX_STRING_SIZE, mail.as_ref().unwrap().len(), IdentityError::StringTooLarge);
        }

        // Enregistrement des données dans notre account Identity
        let user_identity = &mut ctx.accounts.identity;
        user_identity.first_name = first_name;
        user_identity.last_name = last_name;
        user_identity.birth = birth;
        user_identity.mail = mail;
        user_identity.created = Clock::get().unwrap().unix_timestamp;
        user_identity.bump = *ctx.bumps.get("identity").unwrap();

        Ok(())
    }

    /// Permet à un utilisateur de mettre à jour son prénom
    pub fn update_name(ctx: Context<UpdateIdentity>, first_name: String) -> Result<()> {
        require_gte!(Identity::MAX_STRING_SIZE, first_name.len(), IdentityError::StringTooLarge);

        ctx.accounts.identity.first_name = first_name;

        Ok(())
    }

    /// Permet à un utilisateur de mettre à jour son pseudonyme
    pub fn update_username(ctx: Context<UpdateIdentity>, username: String) -> Result<()> {
        require_gte!(Identity::MAX_STRING_SIZE, username.len(), IdentityError::StringTooLarge);

        ctx.accounts.identity.username = username;

        Ok(())
    }

    /// Permet à un utilisateur de mettre à jour ou supprimer son mail
    pub fn update_mail(ctx: Context<UpdateIdentity>, mail: Option<String>) -> Result<()> {
        if mail.is_some() {
            require_gte!(Identity::MAX_STRING_SIZE, mail.as_ref().unwrap().len(), IdentityError::StringTooLarge);
        }

        ctx.accounts.identity.mail = mail;

        Ok(())
    }

    /// Permet à un utilisateur ayant une identité depuis plus de 2 ans
    /// de supprimer son identité 
    pub fn delete_identity(ctx: Context<CloseIdentity>) -> Result<()> {
        let now = Clock::get().unwrap().unix_timestamp;
        let created = ctx.accounts.identity.created;
        let since = now - created;

        require_gt!(since, CAN_DELETE_AFTER, IdentityError::TimeNotPassed);

        Ok(())
    }
}

#[derive(Accounts)]
pub struct CreateIdentity<'info> {
    #[account(mut)]
    pub user: Signer<'info>,
    #[account(
        init,
        payer = user, 
        space = Identity::MAX_IDENTITY_SIZE + 8,
        seeds = [b"Identity", user.key().as_ref()], bump
    )]
    pub identity: Account<'info, Identity>,
    pub system_program: SystemAccount<'info>
}

#[derive(Accounts)]
pub struct UpdateIdentity<'info> {
    pub user: Signer<'info>,
    #[account(
        mut,
        seeds = [b"Identity", user.key().as_ref()], bump = identity.bump
    )]
    pub identity: Account<'info, Identity>
}

#[derive(Accounts)]
pub struct CloseIdentity<'info> {
    pub user: Signer<'info>,
    #[account(
        mut,
        close = user,
        seeds = [b"Identity", user.key().as_ref()], bump = identity.bump
    )]
    pub identity: Account<'info, Identity>
}
Enter fullscreen mode Exit fullscreen mode
use anchor_lang::prelude::*;

/// Définit la structure d'une identité d'un utilisateur
#[account]
pub struct Identity {
    pub first_name: String, // 128 + 4 = 132
    pub last_name: String,  // 128 + 4 = 132
    pub username: String,   // 128 + 4 = 132
    pub birth: i64, // 8
    pub mail: Option<String>, // 128 + 1 = 129
    pub created: i64, // 8
    pub bump: u8 // 1
}

impl Identity {
    pub const MAX_STRING_SIZE: usize = 128;
    pub const MAX_IDENTITY_SIZE: usize = 132 + 132 + 132 + 8 + 129 + 8 + 1;
}
Enter fullscreen mode Exit fullscreen mode
use anchor_lang::error_code;

#[error_code]
pub enum IdentityError {
    #[msg("Specified string is higher than the expected maximum space")]
    StringTooLarge,
    #[msg("2 year is needed since the creation of the identity to be closed")]
    TimeNotPassed
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Vous devriez désormais avoir les clés en mains pour faire vos propres programs Solana !

Une version plus "rustic" du projet est disponible sur mon github.

Dans une prochaine partie, j’expliquerai comment tester les programs que vous produisez, toujours via anchor, avec Typescript + Chai.

J’évoquerai aussi prochainement les tokens sur Solana (SPL), les Cross-Program Invocations (CPI) ou divers articles sur d'autres technologies web3.

Si vous aimez mon contenu et/ou que celui-ci vous aide en tant que développeur, vous êtes le bienvenue sur mes différents réseaux 😊

LinkedIn
Twitter
Instagram

Top comments (0)