Créer un token de staking simple

Daniel KambaleDaniel Kambale
10 min read

Le staking est devenu l'un des piliers fondamentaux de l'écosystème blockchain moderne. Loin d'être simplement une méthode pour générer des rendements passifs, le staking représente une évolution fondamentale dans la manière dont les réseaux décentralisés sécurisent leurs opérations et incitent à la participation communautaire.

Dans cet article, nous allons plonger dans les méandres de Solidity pour développer un contrat intelligent de staking simple mais fonctionnel. Nous verrons comment créer un système où les utilisateurs peuvent verrouiller leurs tokens et recevoir des récompenses proportionnelles à leur contribution. Mais avant de nous lancer dans le code, prenons un moment pour comprendre ce qu'est réellement le staking.

Qu'est-ce que le staking ?

Le staking est un processus par lequel les détenteurs de cryptomonnaies immobilisent leurs actifs pour participer à la maintenance du réseau blockchain. En échange de cette immobilisation, ils reçoivent des récompenses. C'est un peu comme si vous déposiez de l'argent sur un compte d'épargne bloqué, mais avec des mécanismes et des implications bien plus complexes.

Dans notre contexte de développement, nous allons créer un système simple où :

  1. Les utilisateurs peuvent déposer des tokens dans notre contrat

  2. Ces tokens sont verrouillés pendant une période déterminée

  3. Les utilisateurs reçoivent des récompenses basées sur la quantité de tokens déposés et la durée du staking

Prérequis

Pour suivre ce tutoriel, vous aurez besoin de :

  • Une compréhension de base de Solidity et des contrats intelligents

  • Remix IDE ou un environnement de développement Solidity similaire

  • Des connaissances de base sur les ERC-20

  • Une familiarité avec les concepts de DeFi

Structure du contrat

Notre système de staking comprendra deux contrats principaux :

  1. Un token ERC-20 standard que les utilisateurs pourront staker

  2. Un contrat de staking qui gère les dépôts, les retraits et les récompenses

Commençons par développer notre token ERC-20.

Le token ERC-20

Voici un exemple simple d'un token ERC-20 que nous utiliserons pour notre système de staking :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract StakingToken is ERC20, Ownable {
    constructor(uint256 initialSupply) ERC20("Staker Token", "STK") Ownable(msg.sender) {
        _mint(msg.sender, initialSupply * 10 ** decimals());
    }

    // Fonction pour frapper de nouveaux tokens (réservée au propriétaire)
    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
}

Ce contrat est assez simple. Il crée un token ERC-20 standard avec :

  • Un nom : "Staker Token"

  • Un symbole : "STK"

  • Une offre initiale définie lors du déploiement

  • Une fonction de mint pour créer de nouveaux tokens (qui sera utilisée pour les récompenses)

Maintenant, passons à la partie la plus intéressante : le contrat de staking.

Le contrat de staking

Voici notre contrat de staking complet, que nous allons ensuite décomposer et expliquer en détail :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract StakingContract is ReentrancyGuard, Ownable {
    using SafeERC20 for IERC20;

    // Token à staker
    IERC20 public stakingToken;

    // Token de récompense (peut être le même)
    IERC20 public rewardToken;

    // Taux de récompense par bloc (en tokens)
    uint256 public rewardRate;

    // Dernier bloc où les récompenses ont été calculées
    uint256 public lastUpdateBlock;

    // Récompense accumulée par token
    uint256 public rewardPerTokenStored;

    // Suivi des récompenses par utilisateur
    mapping(address => uint256) public userRewardPerTokenPaid;
    mapping(address => uint256) public rewards;

    // Montant total staké
    uint256 public totalStaked;

    // Suivi du stake par utilisateur
    mapping(address => uint256) public balances;

    // Période de blocage (en blocs)
    uint256 public lockPeriod;

    // Suivi du dernier bloc de stake par utilisateur
    mapping(address => uint256) public userLastStakeBlock;

    // Événements
    event Staked(address indexed user, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount);
    event RewardPaid(address indexed user, uint256 reward);

    constructor(
        address _stakingToken,
        address _rewardToken,
        uint256 _rewardRate,
        uint256 _lockPeriod
    ) Ownable(msg.sender) {
        stakingToken = IERC20(_stakingToken);
        rewardToken = IERC20(_rewardToken);
        rewardRate = _rewardRate;
        lockPeriod = _lockPeriod;
        lastUpdateBlock = block.number;
    }

    // Modifier pour mettre à jour les récompenses
    modifier updateReward(address account) {
        rewardPerTokenStored = rewardPerToken();
        lastUpdateBlock = block.number;

        if (account != address(0)) {
            rewards[account] = earned(account);
            userRewardPerTokenPaid[account] = rewardPerTokenStored;
        }
        _;
    }

    // Calcule la récompense par token
    function rewardPerToken() public view returns (uint256) {
        if (totalStaked == 0) {
            return rewardPerTokenStored;
        }

        return rewardPerTokenStored + (
            ((block.number - lastUpdateBlock) * rewardRate * 1e18) / totalStaked
        );
    }

    // Calcule les gains d'un utilisateur
    function earned(address account) public view returns (uint256) {
        return (
            (balances[account] * (rewardPerToken() - userRewardPerTokenPaid[account])) / 1e18
        ) + rewards[account];
    }

    // Fonction de stake
    function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
        require(amount > 0, "Impossible de staker 0");

        totalStaked += amount;
        balances[msg.sender] += amount;
        userLastStakeBlock[msg.sender] = block.number;

        stakingToken.safeTransferFrom(msg.sender, address(this), amount);

        emit Staked(msg.sender, amount);
    }

    // Fonction de retrait
    function withdraw(uint256 amount) external nonReentrant updateReward(msg.sender) {
        require(amount > 0, "Impossible de retirer 0");
        require(balances[msg.sender] >= amount, "Solde insuffisant");
        require(
            block.number >= userLastStakeBlock[msg.sender] + lockPeriod,
            "Période de blocage non terminée"
        );

        totalStaked -= amount;
        balances[msg.sender] -= amount;

        stakingToken.safeTransfer(msg.sender, amount);

        emit Withdrawn(msg.sender, amount);
    }

    // Récupérer les récompenses
    function getReward() external nonReentrant updateReward(msg.sender) {
        uint256 reward = rewards[msg.sender];

        if (reward > 0) {
            rewards[msg.sender] = 0;
            rewardToken.safeTransfer(msg.sender, reward);

            emit RewardPaid(msg.sender, reward);
        }
    }

    // Fonction d'urgence permettant au propriétaire de récupérer les tokens
    function emergencyWithdraw(address token, uint256 amount) external onlyOwner {
        IERC20(token).safeTransfer(owner(), amount);
    }

    // Mettre à jour le taux de récompense
    function setRewardRate(uint256 _rewardRate) external onlyOwner updateReward(address(0)) {
        rewardRate = _rewardRate;
    }

    // Modifier la période de blocage
    function setLockPeriod(uint256 _lockPeriod) external onlyOwner {
        lockPeriod = _lockPeriod;
    }
}

Analyse détaillée du contrat de staking

Variables d'état importantes

// Token à staker
IERC20 public stakingToken;

// Token de récompense (peut être le même)
IERC20 public rewardToken;

// Taux de récompense par bloc (en tokens)
uint256 public rewardRate;

// Montant total staké
uint256 public totalStaked;

// Période de blocage (en blocs)
uint256 public lockPeriod;

Ces variables définissent les paramètres essentiels de notre système :

  • stakingToken : le token que les utilisateurs vont déposer

  • rewardToken : le token qui sera distribué comme récompense (peut être identique au token de staking)

  • rewardRate : le taux auquel les récompenses sont distribuées par bloc

  • totalStaked : le montant total de tokens déposés dans le contrat

  • lockPeriod : la durée pendant laquelle les tokens sont verrouillés

Le mécanisme de récompense

Le calcul des récompenses est l'une des parties les plus complexes d'un système de staking. Notre approche utilise un système de "récompense par token" qui s'accumule avec le temps :

// Calcule la récompense par token
function rewardPerToken() public view returns (uint256) {
    if (totalStaked == 0) {
        return rewardPerTokenStored;
    }

    return rewardPerTokenStored + (
        ((block.number - lastUpdateBlock) * rewardRate * 1e18) / totalStaked
    );
}

// Calcule les gains d'un utilisateur
function earned(address account) public view returns (uint256) {
    return (
        (balances[account] * (rewardPerToken() - userRewardPerTokenPaid[account])) / 1e18
    ) + rewards[account];
}

Cette approche permet de :

  1. Calculer précisément les récompenses dues à chaque utilisateur

  2. Distribuer les récompenses proportionnellement au montant staké

  3. Éviter d'avoir à mettre à jour les récompenses pour tous les utilisateurs à chaque bloc

Le modificateur updateReward

// Modifier pour mettre à jour les récompenses
modifier updateReward(address account) {
    rewardPerTokenStored = rewardPerToken();
    lastUpdateBlock = block.number;

    if (account != address(0)) {
        rewards[account] = earned(account);
        userRewardPerTokenPaid[account] = rewardPerTokenStored;
    }
    _;
}

Ce modificateur est crucial. Il met à jour l'état des récompenses avant chaque action importante (stake, withdraw, getReward). Il permet de s'assurer que les récompenses sont correctement calculées et attribuées.

Les fonctions principales

stake

// Fonction de stake
function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
    require(amount > 0, "Impossible de staker 0");

    totalStaked += amount;
    balances[msg.sender] += amount;
    userLastStakeBlock[msg.sender] = block.number;

    stakingToken.safeTransferFrom(msg.sender, address(this), amount);

    emit Staked(msg.sender, amount);
}

Cette fonction permet aux utilisateurs de déposer leurs tokens. Elle :

  1. Met à jour les récompenses (via le modificateur)

  2. Vérifie que le montant est valide

  3. Met à jour les compteurs internes

  4. Transfère les tokens au contrat

  5. Émet un événement pour traçabilité

withdraw

// Fonction de retrait
function withdraw(uint256 amount) external nonReentrant updateReward(msg.sender) {
    require(amount > 0, "Impossible de retirer 0");
    require(balances[msg.sender] >= amount, "Solde insuffisant");
    require(
        block.number >= userLastStakeBlock[msg.sender] + lockPeriod,
        "Période de blocage non terminée"
    );

    totalStaked -= amount;
    balances[msg.sender] -= amount;

    stakingToken.safeTransfer(msg.sender, amount);

    emit Withdrawn(msg.sender, amount);
}

Cette fonction permet de récupérer les tokens déposés, mais seulement après la période de blocage. Elle vérifie que :

  1. Le montant est valide

  2. L'utilisateur a suffisamment de tokens déposés

  3. La période de blocage est terminée

getReward

// Récupérer les récompenses
function getReward() external nonReentrant updateReward(msg.sender) {
    uint256 reward = rewards[msg.sender];

    if (reward > 0) {
        rewards[msg.sender] = 0;
        rewardToken.safeTransfer(msg.sender, reward);

        emit RewardPaid(msg.sender, reward);
    }
}

Cette fonction permet aux utilisateurs de réclamer leurs récompenses accumulées, indépendamment de leurs tokens stakés.

Déploiement et interaction

Voici comment vous pourriez déployer et utiliser ces contrats dans un environnement réel :

  1. Déployer d'abord le contrat StakingToken avec une offre initiale appropriée

  2. Utiliser l'adresse de ce token pour déployer le contrat StakingContract

  3. Si vous souhaitez utiliser un token différent pour les récompenses, déployez-le également

Exemple de paramètres de déploiement :

  • _stakingToken : l'adresse du token ERC-20 à staker

  • _rewardToken : peut être la même adresse que _stakingToken

  • _rewardRate : par exemple 10 * 10^18 (10 tokens par bloc)

  • _lockPeriod : par exemple 40320 (environ 1 semaine à 15 secondes par bloc)

Cas d'usage réels

Protocole DeFi

Ce type de contrat est couramment utilisé dans les protocoles DeFi. Par exemple, imaginez un protocole de prêt décentralisé comme Aave ou Compound. Les utilisateurs peuvent staker les tokens de gouvernance du protocole pour :

  • Participer aux décisions de gouvernance

  • Recevoir une partie des frais générés par le protocole

  • Obtenir des avantages comme des taux préférentiels

J'ai personnellement travaillé sur un projet où ce mécanisme était utilisé pour incentiver la fourniture de liquidité. Les utilisateurs qui stakaient leurs tokens LP (Liquidity Provider) recevaient des tokens de gouvernance en récompense, créant ainsi un cercle vertueux.

Start-up en blockchain

Une start-up construisant une application décentralisée pourrait utiliser un système de staking pour :

  • Fidéliser ses premiers utilisateurs

  • Distribuer les tokens de manière équitable

  • Assurer un verrouillage progressif des tokens (vesting)

J'ai vu ce système mis en œuvre chez un client qui développait une marketplace NFT. Les utilisateurs stakant leurs tokens obtenaient des réductions sur les frais de transaction et des accès anticipés aux collections exclusives.

Meilleures pratiques et considérations de sécurité

Protection contre la réentrance

Notre contrat utilise le modificateur nonReentrant de la bibliothèque OpenZeppelin pour prévenir les attaques par réentrance. C'est crucial car nous manipulons des tokens et des états financiers.

function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
    // ...
}

Utilisation de SafeERC20

Nous utilisons la bibliothèque SafeERC20 pour interagir avec les tokens ERC-20. Certains tokens non standard peuvent se comporter de manière inattendue, et cette bibliothèque offre une protection supplémentaire.

using SafeERC20 for IERC20;
// ...
stakingToken.safeTransferFrom(msg.sender, address(this), amount);

Fonction d'urgence

Nous avons inclus une fonction d'urgence permettant au propriétaire de récupérer les tokens en cas de problème :

function emergencyWithdraw(address token, uint256 amount) external onlyOwner {
    IERC20(token).safeTransfer(owner(), amount);
}

Cette fonction doit être utilisée avec précaution, mais peut être cruciale en cas de découverte d'une vulnérabilité.

Test des calculs de récompense

Les calculs de récompense sont complexes et sujets à des erreurs d'arrondi. Il est essentiel de tester minutieusement différents scénarios :

  • Multiple utilisateurs stakant et retirant à différents moments

  • Modification du taux de récompense pendant la période de staking

  • Cas limites comme totalStaked = 0

Audit du contrat

Avant de déployer un tel contrat en production, il est fortement recommandé de faire réaliser un audit par une entreprise spécialisée en sécurité blockchain comme OpenZeppelin, ChainSecurity ou Trail of Bits.

Dans cet article, nous avons exploré la création d'un système de staking simple mais fonctionnel sur Ethereum. Nous avons vu comment :

  • Mettre en place un token ERC-20 stakable

  • Créer un contrat de staking avec des récompenses

  • Gérer les dépôts, retraits et distributions de récompenses

  • Appliquer les meilleures pratiques de sécurité

Le staking est devenu un mécanisme fondamental dans l'écosystème blockchain, offrant une alternative moins énergivore au Proof of Work tout en incentivant la participation et la fidélité des utilisateurs.

Bien que notre implémentation soit relativement simple, elle constitue une base solide que vous pouvez étendre et personnaliser selon vos besoins spécifiques. Vous pourriez, par exemple, ajouter des fonctionnalités comme :

  • Des périodes de staking variables avec des récompenses différentes

  • Un système de boost pour les utilisateurs qui stakent plus longtemps

  • L'intégration avec un système de gouvernance

  • Des récompenses dynamiques basées sur la performance du protocole

N'hésitez pas à expérimenter et à adapter ce code à vos propres projets. Et surtout, n'oubliez jamais de prioriser la sécurité lorsque vous travaillez avec des contrats financiers sur la blockchain.

Si vous avez des questions ou des retours, n'hésitez pas à les partager dans les commentaires ci-dessous. Bon développement !

30
Subscribe to my newsletter

Read articles from Daniel Kambale directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Daniel Kambale
Daniel Kambale

Hello! I’m Daniel, a Web3 developer specializing in Solidity and smart contract development. My journey in the blockchain space is driven by a vision of a fully decentralized world, where technology empowers individuals and transforms industries. As a Web3 ambassador , I’m committed to fostering growth and innovation in this space, helping to shape a future that values transparency and security. Fluent in both English and French, I enjoy connecting with diverse communities and sharing my insights across languages. This is why you’ll find some of my articles in French, while others are in Swahili, as I believe knowledge should be accessible to all. I use my Hashnode blog to document my learning process, explore decentralized solutions, and share practical tutorials on Web3 development. Whether it's diving deep into Solidity, discussing the latest in blockchain, or exploring new tools, I’m passionate about contributing to a decentralized future and connecting with others who share this vision. Let’s build the future together!