Récemment, j’ai écrit un tutoriel sur ce blog expliquant comment créer un nouveau jeton sur la blockchain Solana en utilisant la Solana Program Library (SPL), qui est la méthode officielle pour cela. Dans le tutoriel d’aujourd’hui, je veux montrer comment vous utilisez des jetons existants dans la création de protocoles DeFi au sein de cet écosystème, en utilisant la programmation en Rust avec le cadre Anchor.
Pour pouvoir tirer le meilleur parti de ce tutoriel et le comprendre, il est nécessaire que vous maîtrisiez déjà les bases de la programmation DeFi sur Solana, ainsi que les fondements du fonctionnement des jetons sur ce même réseau.
Allons-y !
#1 – Structure générale du protocole
En prenant comme base le tutoriel précédent sur la programmation DeFi et en l’adaptant pour qu’il utilise un spl-token au lieu de SOL, nous pouvons démarrer le développement en conservant certaines structures de base qui nous servent également dans cette nouvelle configuration. Pour une explication détaillée de celles-ci, reportez-vous au tutoriel précédent.
#[account]
pub struct Vault {
pub owner: Pubkey,
pub balance: u64,
}
#[error_code]
pub enum VaultError {
#[msg("Amount must be greater than zero")]
InvalidAmount,
#[msg("Insufficient balance")]
InsufficientBalance,
}
#[event]
pub struct DepositEvent {
pub user: Pubkey,
pub amount: u64,
pub new_balance: u64,
}
#[event]
pub struct WithdrawEvent {
pub user: Pubkey,
pub amount: u64,
pub new_balance: u64,
}
use anchor_spl::associated_token::get_associated_token_address_with_program_id;
use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked}
Mais pour qu’elle soit disponible, vous devez ouvrir le Cargo.toml le plus proche (qui se trouve dans le dossier du programme) et ajuster les paramètres ci-dessous, les autres configurations devant rester inchangées :
[features]
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
[dependencies]
anchor-lang = { version = "0.32.1", features = ["init-if-needed"] }
anchor-spl = "0.32.1"
E dans le Cargo.toml plus loin, qui se trouve à la racine du projet, j’ai dû effectuer ce réglage :
[patch.crates-io]
blake3 = { git = "https://github.com/BLAKE3-team/BLAKE3", tag = "1.8.2" }
En ce qui concerne les tests unitaires, nous devons installer la bibliothèque @solana/spl-token via NPM.
npm install @solana/spl-token
Et ensuite on l’importe et on effectue l’assertion dans le module de tests.
import assert from "assert";
import {
TOKEN_2022_PROGRAM_ID,
createMint,
getAssociatedTokenAddress,
createAssociatedTokenAccount,
mintTo,
} from "@solana/spl-token";
Déjà, dans les variables globales des tests, nous aurons certaines bien connues et d’autres spécifiques à ce lot de tests.
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const user = provider.wallet.publicKey;
const program = anchor.workspace.tokenProtocolAnchor as Program<TokenProtocolAnchor>;
let vaultPda: anchor.web3.PublicKey;
let mint: anchor.web3.PublicKey;
let userTokenAccount: anchor.web3.PublicKey;
A saber :
- provider : communication avec la blockchain de test;
- user : portefeuille qui va exécuter les tests;
- program : le programme à tester (TokenProtocolAnchor dans ce cas);
- vaultPda : le PDA du coffre-fort (vault) que nous utiliserons dans les tests;
- mint : l’adresse du spl-token que nous allons utiliser dans les tests;
- userTokenAccount : le compte de tokens de l’utilisateur que nous allons utiliser dans les tests;
Certaines de ces variables, ainsi que d’autres préparatifs, nous les ferons dans la fonction before, qui s’exécutera avant le premier test.
before(async () => {
[vaultPda] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("vault"), user.toBuffer()],
program.programId
);
// create Token2022 mint and give user some tokens
mint = await createMint(
provider.connection,
provider.wallet.payer,
provider.wallet.publicKey,
null,
9, // decimals
undefined,
undefined,
TOKEN_2022_PROGRAM_ID
);
await createAssociatedTokenAccount(
provider.connection,
provider.wallet.payer,
mint,
user,
undefined,
TOKEN_2022_PROGRAM_ID
);
// mint some tokens to user account
userTokenAccount = await getAssociatedTokenAddress(mint, user, false, TOKEN_2022_PROGRAM_ID);
await mintTo(
provider.connection,
provider.wallet.payer,
mint,
userTokenAccount,
provider.wallet.publicKey,
10_000_000_000, // 10 tokens with decimals 9
undefined,
undefined,
TOKEN_2022_PROGRAM_ID
);
});
Nous commençons le before en calculant l’adresse PDA du vault Token Account et en la stockant dans la variable correspondante.
Ensuite, nous créons un nouveau mint Token SPL-Token pour les tests, afin de simuler une devise réelle. createMint attend la connexion à la blockchain, le portefeuille qui paiera le loyer, la pubkey qui sera l’autorité d’émission de la nouvelle monnaie, la valeur de freeze (null), le nombre de décimales, mais deux paramètres optionnels et, enfin, l’identifiant du programme à utiliser, ici nous travaillons avec la norme la plus récente, spl-token-2022.
Par la suite, nous créons l’ATA pour l’utilisateur de tests avec la fonction createAssociatedTokenAccount, qui attend la connexion, le payeur du loyer, le token mint, l’utilisateur propriétaire de ce compte et, en dernier lieu, l’identifiant du programme à utiliser.
Enfin, nous utilisons getAssociatedTokenAddress pour calculer l’ATA de notre utilisateur des tests et, avec cette information, nous minterons quelques jetons pour qu’il ait un solde dans les tests (10 jetons avec décimales 9).
Avec cela, nous avons effectué les préparatifs initiaux.
#2 – Dépôt de SPL-Token
Pour pouvoir effectuer le dépôt d’un spl-token, nous devons d’abord créer le contexte pour celui-ci, comme ci-dessous.
Avec les champs suivants :
- vault : contrôle de notre protocole sur le solde déposé par chaque utilisateur. Compte avec init_if_needed pour être créé s’il n’existe pas encore (ce qui doit être activé dans le Cargo.toml) et utilise comme seed la pubkey de l’utilisateur afin d’assurer que chacun dispose d’un seul vault;
- mint : le Mint Token Account, qui représente la monnaie avec laquelle nous travaillons, avec la contrainte que le programme qui l’a créée soit le même que celui passé ci-dessous. Remarquez aussi que j’utilise le type InterfaceAccount plutôt que Account, car il s’agit du spl-token-2022;
- user_token_account : le Token Account qui va déposer, qui doit être l’ATA de l’utilisateur et avoir été créée avec le programme de token présent dans le contexte;
- vault_token_account : le Token Account du vault, qui recevra le dépôt et, plus tard, effectuera les transferts de retrait. Ce compte est créé automatiquement si nécessaire, il est unique par utilisateur (utilise la pubkey dans seed) et a comme contraintes supplémentaires que le jeton Mint de ce nouveau compte soit le même que celui du champ mint du contexte, que l’authority de ce compte soit le vault et que le programme soit le même que celui passé;
- user : l’utilisateur qui va effectuer/signature le dépôt;
- token_program : le programme SPL-Token qui va faire le transfert du dépôt, utilisant ici le type TokenInterface car nous utilisons spl-token-2022;
- system_program : le programme système qui va créer les comptes;
- rent : la propriété exigée en interne par la spl-token pour pouvoir payer la token account créée (s’il y en a).
Attention à la dynamique entre user_token_account et vault_token_account. Dans le dépôt, le solde quitte le token account de l’utilisateur pour aller vers le vault_token_account, alors que lors du retrait c’est l’inverse.
Maintenant, passons à la fonction de dépôt, qui va utiliser ce contexte, comme ci-dessous :
pub fn deposit(ctx: Context<DepositContext>, amount: u64) -> Result<()> {
require!(amount > 0, VaultError::InvalidAmount);
require!(ctx.accounts.user_token_account.amount >= amount, VaultError::InsufficientBalance);
// Transfer SPL tokens from user → vault token account
let cpi_accounts = TransferChecked {
from: ctx.accounts.user_token_account.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
to: ctx.accounts.vault_token_account.to_account_info(),
authority: ctx.accounts.user.to_account_info(),
};
let cpi_ctx = CpiContext::new(ctx.accounts.token_program.to_account_info(), cpi_accounts);
token_interface::transfer_checked(cpi_ctx, amount, ctx.accounts.mint.decimals)?;
// Update vault state
let vault = &mut ctx.accounts.vault;
vault.owner = ctx.accounts.user.key();
vault.balance = vault.balance.checked_add(amount).unwrap();
emit!(DepositEvent {
user: ctx.accounts.user.key(),
amount,
new_balance: vault.balance,
});
Ok(())
}
Nous commençons par tester si le montant est valide avec la macro require! et en cas de besoin, nous lançons l’erreur personnalisée. Il en est de même pour la vérification du solde dans le compte de tokens de l’utilisateur.
Ensuite, comme le transfert se fait via notre programme appelant le programme spl-token, nous devons effectuer une Cross-Program Invocation (CPI). Nous commençons par configurer un objet TransferChecked dans lequel nous indiquons le compte source (from) : utilisateur, le compte destination (to) : coffre (vault), le token (mint) et l’autorité qui va signer le transfert (utilisateur).
Ensuite, nous chargeons un nouveau CpiContext avec le programme de token et les cpi_accounts et ordonnons d’effectuer le transfert avec ce dernier.
Une fois le transfert terminé, il est temps de mettre à jour le vault, afin d’avoir le contrôle interne de notre programme, ce que nous faisons en définissant le bon propriétaire et le nouveau solde, incrémenté de manière sûre grâce à checked_add et unwrap. À la fin de la mise à jour des contrôles, nous émettons l’événement de dépôt.
#3 – Tests de Dépôt
Maintenant que tout est programmé pour réaliser des dépôts, revenons à notre fichier de tests. Commençons par créer une fonction qui effectue uniquement le dépôt lui-même, que nous utiliserons dans plusieurs tests.
async function deposit(depositAmount: anchor.BN) {
const [vaultTokenPda] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("vault-token"), user.toBuffer()],
program.programId
);
await program.methods
.deposit(depositAmount)
.accounts({
vault: vaultPda,
mint,
userTokenAccount,
vaultTokenAccount: vaultTokenPda,
user,
tokenProgram: TOKEN_2022_PROGRAM_ID,
systemProgram: anchor.web3.SystemProgram.programId,
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
})
.rpc();
}
Cette fonction attend la quantité à déposer et la première chose qu’elle fait est de calculer l’adresse PDA du token account du vault qui va recevoir le dépôt, n’oublions pas qu’il n’y a qu’un vault et un vault token account par utilisateur.
Ensuite, nous appelons la fonction deposit en indiquant le montant et en passant dans accounts le vault PDA, le mint token account, le user token account, le vault token account, l’utilisateur qui dépose, l’identifiant du programme SPL-token, l’identifiant du programme system et le sysvar de rent pour l’usage interne du SPL-token.
Maintenant, écrivons un premier scénario de dépôt réussi, où l’utilisateur dépose 1 jeton dans notre protocole.
it("should successfully deposit tokens into vault", async () => {
const depositAmount = new anchor.BN(1_000_000_000); // 1 token (9 decimals)
await deposit(depositAmount);
const vaultAccount = await program.account.vault.fetch(vaultPda);
assert.equal(vaultAccount.balance.toNumber(), depositAmount.toNumber());
});
Nous commençons par définir la quantité, puis appelons la fonction de dépôt et enfin vérifions le vault pour voir si le solde a été mis à jour correctement.
Maintenant, écrivez un autre test, un scénario d’échec lors du dépôt.
it("should fail when depositing zero or negative amount", async () => {
const invalidAmount = new anchor.BN(0);
try {
await deposit(invalidAmount);
assert.fail("Should have thrown an error");
} catch (error) {
assert.match(error.message, /InvalidAmount/);
}
});
Ici, il n’y a rien de particulier, c’est le même test que nous avions déjà dans le protocole précédent.
#4 – Retrait de SPL-Token
Pour pouvoir effectuer le retrait d’un spl-token, nous devons d’abord créer le contexte pour celui-ci, comme ci-dessous.
#[derive(Accounts)]
pub struct WithdrawContext<'info> {
#[account(
mut,
seeds = [b"vault", user.key().as_ref()],
bump,
constraint = vault.owner == user.key()
)]
pub vault: Account<'info, Vault>,
#[account(mint::token_program = token_program)]
pub mint: InterfaceAccount<'info, Mint>,
#[account(
mut,
token::mint = mint,
token::authority = user,
token::token_program = token_program,
constraint = user_token_account.key() == get_associated_token_address_with_program_id(&user.key(), &mint.key(), &token_program.key())
)]
pubuser_token_account: InterfaceAccount<'info, TokenAccount>,
#[account(
mut,
seeds = [b"vault-token", user.key().as_ref()],
bump,
token::mint = mint,
token::authority = vault,
token::token_program = token_program
)]
pub vault_token_account: InterfaceAccount<'info, TokenAccount>,
#[account(mut)]
pub user: Signer<'info>,
pub token_program: Interface<'info, TokenInterface>,
}
Avec les champs suivants :
- vault : contrôle de notre protocole sur le solde déposé par chaque utilisateur. Ici, on suppose qu’il a déjà été créé par l’appel de dépôt, en utilisant uniquement les seeds et bump comme contraintes, en plus d’une contrainte spécifique garantissant que seul le propriétaire du vault peut appeler la fonction ;
- mint : le Mint Token Account, qui représente la monnaie sur laquelle nous travaillons. Il utilise le type InterfaceAccount car il s’agit du standard spl-token-2022 et cela est renforcé par la contrainte;
- vault_token_account : le Token Account du vault, qui va transférer le retrait vers l’utilisateur. Ce compte a déjà été créé et utilise les contraintes pour garantir que seul le propriétaire peut effectuer le retrait et uniquement pour la bonne monnaie (mint) et le bon programme;
- user_token_account : le Token Account de l’utilisateur qui va retirer;
- user : l’utilisateur qui va effectuer/signature le retrait;
- token_program : le programme SPL-Token qui va effectuer le transfert de retrait, selon le standard 2022 (TokenInterface);
Notez que, contrairement au contexte de dépôt, ici nous n’avons pas besoin du système (system_program) car il n’y a pas création de nouveaux comptes et nous n’avons pas besoin des informations de rent pour la même raison.
Maintenant, passons à la fonction de retrait, qui va utiliser ce contexte, comme ci-dessous :
pub fn withdraw(ctx: Context<WithdrawContext>, amount: u64) -> Result<()> {
require!(amount > 0, VaultError::InvalidAmount);
require!(ctx.accounts.vault.balance >= amount, VaultError::InsufficientBalance);
let user_key = ctx.accounts.user.key();
let user_key_ref = user_key.as_ref();
let (_vault_key, vault_bump) = Pubkey::find_program_address(&[b"vault", user_key_ref], ctx.program_id);
let vault_seeds: &[&[u8]] = &[b"vault", user_key_ref, &[vault_bump]];
let signer_seeds: &[&[&[u8]]] = &[vault_seeds];
let new_balance = ctx.accounts.vault.balance.checked_sub(amount).unwrap();
ctx.accounts.vault.balance = new_balance;
// perform CPI transfer from vault token account to user token account
let cpi_accounts = TransferChecked {
from: ctx.accounts.vault_token_account.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
to: ctx.accounts.user_token_account.to_account_info(),
authority: ctx.accounts.vault.to_account_info(),
};
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
signer_seeds);
token_interface::transfer_checked(cpi_ctx, amount, ctx.accounts.mint.decimals)?;
emit!(WithdrawEvent {
user: ctx.accounts.user.key(),
amount,
new_balance: ctx.accounts.vault.balance,
});
Ok(())
}
Nous commençons par tester que le montant est valide avec la macro require! et en lançant l’erreur personnalisée si nécessaire, y compris un second test pour vérifier que le vault dispose d’un solde suffisant (balance).
Par la suite, nous devons effectuer quelque chose d’un peu plus complexe, à savoir générer les signer seeds du vault PDA. En effet, les PDAs ne peuvent pas signer des transactions par défaut car ils ne possèdent pas de clé privée. Ainsi, afin que le protocole puisse appeler en nom du vault PDA, il faut disposer de ses seeds, y compris le bump. Cela se fait en dérivant son adresse à nouveau puis en régénérant les octets de ses seeds + bump.
Avant d’effectuer le transfert lui-même, nous mettons à jour le balance du vault, selon le modèle Checks-Effects-Interactions.
Comme le transfert s’effectue avec notre programme appelant le programme spl-token, nous devons effectuer une Cross-Program Invocation (CPI). Nous commençons par configurer un objet TransferChecked dans lequel nous indiquons le compte source (from) : vault, le token (mint), le destinataire (to) : user et l’autorité qui va « signer » le transfert (vault).
Ensuite, nous chargeons un nouveau CpiContext avec le programme de token et les cpi_accounts et ordonnons d’effectuer le transfert avec lui, en le signant grâce au signer_seeds.
Une fois le transfert terminé, le vault a déjà été mis à jour au début de la fonction, nous émettons donc simplement l’événement de retrait.
#5 – Tests de Retrait
Maintenant que tout est programmé pour effectuer des retraits, revenons dans notre fichier de tests. Nos tests de retrait devront utiliser la fonction de dépôt, car malheureusement les tests partagent la même infrastructure et les mêmes états des programmes et des comptes. Écrivons un premier scénario de retrait réussi, où l’utilisateur retire 0,5 jeton dans notre protocole.
it("should successfully withdraw tokens from vault", async () => {
const withdrawAmount = new anchor.BN(500_000_000); // 0.5 token
await deposit(withdrawAmount);
const [vaultTokenPda] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("vault-token"), user.toBuffer()],
program.programId
);
const beforeBal = await provider.connection.getTokenAccountBalance(vaultTokenPda);
await program.methods
.withdraw(withdrawAmount)
.accounts({
vault: vaultPda,
mint,
vaultTokenAccount: vaultTokenPda,
userTokenAccount,
user,
tokenProgram: TOKEN_2022_PROGRAM_ID,
})
.rpc();
const afterBal = await provider.connection.getTokenAccountBalance(vaultTokenPda);
assert.ok(
beforeBal.value.uiAmount ===
afterBal.value.uiAmount! + withdrawAmount.toNumber() / 10 ** 9
);
});
Juste après que la variable de quantité et le dépôt initial aient été initialisés, nous dérivons le PDA du vault-token et prenons le solde initial en utilisant getTokenAccountBalance qui est déjà prête dans @solana/web3.js, spécialement pour gérer les SPL-tokens (injection dans l’objet connection d’Anchor).
L’appel lui-même pour le retrait n’exige pas de grandes explications, il faut juste prêter attention au passage correct des paramètres, qui sont le vault PDA, le mint, le vault token account (PDA également), le user token account, le signer (utilisateur) et le programme spl-token (2022).
Après le retrait, on prend une nouvelle échantillon du solde et on vérifie qu’il a été correctement mis à jour.
Maintenant, écrivons un autre scénario pour couvrir la possibilité de ne pas disposer d’un solde suffisant pour le retrait.
it("should fail when withdrawing more than available balance", async () => {
const excessiveAmount = new anchor.BN(10_000_000_000_000); // bien trop de jetons
const [vaultTokenPda] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("vault-token"), user.toBuffer()],
program.programId
);
try {
await program.methods
.withdraw(excessiveAmount)
.accounts({
vault: vaultPda,
mint,
vaultTokenAccount: vaultTokenPda,
userTokenAccount,
user,
tokenProgram: TOKEN_2022_PROGRAM_ID,
})
.rpc();
assert.fail("Should have thrown an error");
} catch (error) {
assert.match(error.message, /InsufficientBalance/);
}
});
Ici, il n’y a rien de nouveau dans la programmation elle-même, seulement dans la logique du test.
Et enfin, écrivons aussi un test unitaire pour couvrir le scénario d’une tentative de retrait de pièces qui ne vous appartiennent pas.
it("should fail when withdrawing from the wrong account", async () => {
const depositAmount = new anchor.BN(1_000_000_000); // 1 token
await deposit(depositAmount);
let newUser = anchor.web3.Keypair.generate();
const withdrawAmount = new anchor.BN(500_000_000); // 0.5 token
const [vaultTokenPda] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("vault-token"), user.toBuffer()],
program.programId
);
try {
await program.methods
.withdraw(withdrawAmount)
.accounts({
vault: vaultPda,
mint,
vaultTokenAccount: vaultTokenPda,
userTokenAccount,
user: newUser.publicKey,
tokenProgram: TOKEN_2022_PROGRAM_ID,
})
.signers([newUser])
.rpc();
assert.fail("Should have thrown an error");
} catch (error) {
assert.match(error.message, /ConstraintSeeds/);
}
});
Ici, la seule différence est la génération d’un nouveau compte wallet uniquement pour être utilisé dans la signature de la transaction, afin d’obliger que ce soit quelqu’un d’autre qui essaye de retirer les jetons du compte utilisateur par défaut des tests (qui en sont les véritables propriétaires).
Maintenant, si vous lancez un anchor build, vous devriez obtenir le résultat suivant.
J’espère que vous avez apprécié ce nouvel article.
À la prochaine !




