This content originally appeared on DEV Community and was authored by Loïs Lagoutte
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
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)"
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
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
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
Vous pouvez ensuite installer la dernière version d’anchor via l’avm:
avm install latest
avm use latest
Pour vérifier qu’anchor est bien installé:
anchor --version
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>
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 danslib.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 leContext
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 {}
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")
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
Vous devriez voir apparaitre votre program ainsi que sa clé publique.
❯ anchor keys list
identity: GxyJLSDuC7BkeorKoMg87uhaXvuaUxDjDKT7iQWCbxXJ
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");
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"
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;
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 {}
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
}
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
etcreated
contiennent une date sous la forme d’un Unix timestamp. Pour une optimisation de taille,i32
serait préférable ài64
, maisi32
est dangereux et pourrait rendre le program inutilisable le jour où l’unix timestamp dépasse la limite de taille d’uni32
(en l’an 2038...).i64
nous assure une utilisation sans problème jusqu’en l’an 2262 😃 ! - Notre champ
mail
étant optionnel, le typiqueOption<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:
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
}
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;
}
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(())
}
}
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> {}
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 queT
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 programT
oùT
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é. Unpayer
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>
}
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>
}
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());
}
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;
/// 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(())
}
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>
}
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)
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)
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>
}
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
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
}
!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();
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>
CloseIdentity
#[account(
mut,
close = user,
seeds = [b"Identity", user.key().as_ref()], bump = identity.bump
)]
pub identity: Account<'info, Identity>
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:
- Vérification de la donnée
- 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(())
}
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(())
}
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(())
}
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(())
}
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,
}
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
});
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
}
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
Il ne reste plus qu’à implémenter nos erreurs personnalisées dans nos instructions !
require_gt!(since, CAN_DELETE_AFTER, IdentityError::TimeNotPassed);
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>
}
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;
}
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
}
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 😊
This content originally appeared on DEV Community and was authored by Loïs Lagoutte
Loïs Lagoutte | Sciencx (2022-04-05T23:29:46+00:00) French Solana dev #1: Développer un program (smart-contract) sur la blockchain Solana avec le framework Anchor ⚓️🧑💻. Retrieved from https://www.scien.cx/2022/04/05/french-solana-dev-1-developper-un-program-smart-contract-sur-la-blockchain-solana-avec-le-framework-anchor-%e2%9a%93%ef%b8%8f%f0%9f%a7%91%f0%9f%92%bb/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.