Photo by Arnaud Jaegers / Unsplash

Créons un système de vote décentralisé avec Ethereum

solidity 27 mai 2023
Il y a quelques jours, un étudiant en école d'ingénieurs m'a contacté sur ComeUp pour que je lui réalise un exercice de développement Solidity. Au-delà du fait que je n'ai pas accepté le deal pour une raison éthique, j'ai toutefois trouvé le sujet intéressant. À travers ce tutoriel, je vais vous montrer comment réaliser un système de vote décentralisé simple avec Ethereum.

Pré-requis

Pour notre projet, nous utiliserons l'IDE vscode, Hardhat et Remix.
Hardhat est un environnement de developpement qui nous offre de nombreux outils pour aider les developpeurs à écrire, tester, débogguer et déployer des contrats intelligents en Solidity
Enfin Remix, est un IDE online également complet ayant globalement les mêmes fonctionnalités que Hardhat.

Assurez vous que NPM, NodeJs soient bien installés sur votre ordinateur

Mise en place

  1. Créez un dossier Votes et placez-vous dedans.
  2. Ouvrez VSCode à partir de ce sous-dossier
  3. Lancer un Terminal avec la commande :
    npm install --save-dev hardhat
  4. Puis : 'npm install @nomicfoundation/hardhat-toolbox'
  5. puis : npm install @openzeppelin/contracts
  6. et enfin npm install --save-dev @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers
  7. Maintenant créons le projet avec npx hardhat et laissez vous guider en choisissant les options par défaut.

L'énoncé

Voici l'énoncé complet de l'exercice de création du système de vote reçu :

Étape 1 : création du smart contract

Un contrat intelligent de vote peut varier de simple à complexe, en fonction des besoins des élections que vous voulez organiser. Le vote peut concerner un nombre restreint de propositions (ou de candidats) prédéterminées ou un grand nombre de propositions soumises dynamiquement par les votants eux-mêmes.

Dans ce contexte, vous allez rédiger un contrat intelligent de vote pour une petite organisation. Les votants, tous connus de l'organisation, sont inscrits sur une liste blanche (whitelist) à l'aide de leur adresse Ethereum, peuvent proposer de nouvelles idées lors d'une session d'enregistrement des propositions et peuvent voter sur les propositions lors de la session de vote.

✔️ Le vote n'est pas confidentiel pour les utilisateurs ajoutés à la liste blanche.
✔️ Chaque votant peut consulter les votes des autres.
✔️ Le vainqueur est déterminé à la majorité simple.
✔️ La proposition qui reçoit le plus de votes l'emporte.
✔️ N'oubliez pas que votre code doit inspirer confiance et veiller à respecter les règles établies !

👉 Le processus de vote :

Voici comment se déroule l'ensemble du processus de vote :

  • L'administrateur du vote inscrit une liste blanche d'électeurs identifiés par leur adresse Ethereum.
  • L'administrateur du vote démarre la session d'enregistrement des propositions.
    Les électeurs inscrits peuvent soumettre leurs propositions pendant que la session d'enregistrement est active.
  • L'administrateur du vote clôture la session d'enregistrement des propositions.
  • L'administrateur du vote lance la session de vote.
  • Les électeurs inscrits votent pour leur proposition favorite.
  • L'administrateur du vote clôture la session de vote.
  • L'administrateur du vote comptabilise les votes.
  • Tout le monde peut vérifier les derniers détails de la proposition gagnante.

👉 Les recommandations et exigences :

Votre contrat intelligent doit s'appeler "Voting".

Votre contrat intelligent doit utiliser la dernière version du compilateur.

L'administrateur est celui qui déploie le contrat intelligent.

Votre contrat intelligent doit définir les structures de données suivantes :

struct Voter {
    bool isRegistered;
    bool hasVoted;
    uint votedProposalId;
    }
    
struct Proposal {
    string description;
    uint voteCount;
    }

Votre contrat intelligent doit définir une énumération qui gère les différents états d'un vote

enum WorkflowStatus {
    RegisteringVoters,
    ProposalsRegistrationStarted,
    ProposalsRegistrationEnded,
    VotingSessionStarted,
    VotingSessionEnded,
    VotesTallied
}

Votre contrat intelligent doit définir un uint winningProposalId qui représente l'ID du gagnant ou une fonction getWinner qui retourne le gagnant.

Votre contrat intelligent doit importer le contrat intelligent de la bibliothèque "Ownable" d'OpenZeppelin.

Votre contrat intelligent doit définir les événements suivants :

event VoterRegistered(address voterAddress);
event WorkflowStatusChange(WorkflowStatus previousStatus, WorkflowStatus newStatus);
event ProposalRegistered(uint proposalId);
event Voted (address voter, uint proposalId);

Étape 2 : création de la Dapp

Spécification

Votre Dapp doit permettre :

  • l’enregistrement d’une liste blanche d'électeurs.
  • à l'administrateur de commencer la session d'enregistrement de la proposition.
  • aux électeurs inscrits d’enregistrer leurs propositions.
  • à l'administrateur de mettre fin à la session d'enregistrement des propositions.
  • à l'administrateur de commencer la session de vote.
  • aux électeurs inscrits de voter pour leurs propositions préférées.
  • à l'administrateur de mettre fin à la session de vote.
  • à l'administrateur de comptabiliser les votes.
  • à tout le monde de consulter le résultat.

Les recommandations et exigences

  • Votre code doit être optimal.
  • Votre Dapp doit être sécurisée.
Voilà l'énoncé est claire, complet et fourni de nombreuses informations concernant l'implémentation de notre smart contract

Résolution de l'exercice

Dans notre solution VSCode commençons par créer un fichier Voting.sol dans le sous-dossier contracts

phase d'initialisation

Supprimer le fichier Lock.sol qui se trouve dans le dossier contracts.

Mettons en place la structure de notre smart contract en prenant en compte tous les critères imposés : l'utilisation de Ownable, les structures, les évènements.

1. Initialisation du contrat et des structures de données.

Assurez-vous que votre fichier hardhat.config.js contient bien la version 0.8.18 du compilateur.

Mettons en place le squelette du programme :

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

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

contract Voting is Ownable {

    struct Voter {
        bool isRegistered;
        bool hasVoted;
        uint votedProposalId;
    }

    struct Proposal {
        string description;
        uint voteCount;
    }

    enum WorkflowStatus {
        RegisteringVoters,
        ProposalsRegistrationStarted,
        ProposalsRegistrationEnded,
        VotingSessionStarted,
        VotingSessionEnded,
        VotesTallied
    }
}

Notre structure est en place.

2. Ajout des variables d'état

Afin de stocker les informations sur les votants, les propositions et l'état actuel du processus de vote, ajoutons les variables d'état suivantes dans le contrat :

mapping(address => Voter) public voters;
Proposal[] public proposals;
WorkflowStatus public workflowStatus;

Compilons le contrat à ce stade pour vérifier qu'il n'y a pas d'erreur avec le terminal : npx hardhat compile

3. Ajout des fonctions permettant l'inscription des votants

Ajoutons la fonction registerVoter pour inscrire un électeur.

function registerVoter(address _voterAddress) public onlyOwner {
    require(workflowStatus == WorkflowStatus.RegisteringVoters, "Can't register voters at this time.");
    require(!voters[_voterAddress].isRegistered, "The voter is already registered.");

    voters[_voterAddress] = Voter({
        isRegistered: true,
        hasVoted: false,
        votedProposalId: 0
    });

    emit VoterRegistered(_voterAddress);
}

et ajoutons un évènement lorsqu'un électeur est enregistré :

event VoterRegistered(address voterAddress);

Fonctionnement :

  • registerVoter(address _voterAddress): Cette fonction est utilisée par l'administrateur (grâce au modificateur onlyOwner) pour enregistrer un électeur en utilisant son adresse Ethereum. La fonction vérifie d'abord que le contrat est actuellement en état RegisteringVoters (état d'enregistrement des électeurs) et que l'adresse fournie n'a pas été déjà enregistrée comme électeur. Si ces conditions sont remplies, la fonction crée un nouvel électeur avec l'adresse donnée, le marque comme enregistré et initialise le reste de ses propriétés. Enfin, la fonction émet un événement VoterRegistered pour notifier que l'électeur a été enregistré avec succès.

4. Ajout de fonctions pour gérer les propositions

Commençons par ajouter la fonction permettant de lancer la session d'enregistrement des propositions :

function startProposalsRegistration() public onlyOwner {
    workflowStatus = WorkflowStatus.ProposalsRegistrationStarted;

    emit WorkflowStatusChange(WorkflowStatus.RegisteringVoters, workflowStatus);
}

Ajoutons la fonction permettant d'enregistrer une proposition :

function registerProposal(string memory _description) public {
    require(workflowStatus == WorkflowStatus.ProposalsRegistrationStarted, "Can't register proposals at this time.");
    require(voters[msg.sender].isRegistered, "Only registered voters can submit proposals.");

    proposals.push(Proposal({
        description: _description,
        voteCount: 0
    }));

    emit ProposalRegistered(proposals.length - 1);
}

Ajoutons les évenements suivants dans le contrat :

event WorkflowStatusChange(WorkflowStatus previousStatus, WorkflowStatus newStatus);
event ProposalRegistered(uint proposalId);

Compilons de nouveau le contrat pour vérifier si tout semble correct avec npx hardhat compile

Fonctionnement :

  • startProposalsRegistration(): Cette fonction est utilisée par l'administrateur pour commencer la session d'enregistrement des propositions. Elle change l'état du contrat à ProposalsRegistrationStarted (début de l'enregistrement des propositions) et émet un événement WorkflowStatusChange pour notifier du changement d'état.
  • registerProposal(string memory _description): Cette fonction est utilisée par un électeur enregistré pour enregistrer une nouvelle proposition. Elle vérifie d'abord que le contrat est actuellement en état ProposalsRegistrationStarted et que l'électeur est enregistré. Si ces conditions sont remplies, la fonction crée une nouvelle proposition avec la description donnée, l'ajoute à la liste des propositions et émet un événement ProposalRegistered.

5. Ajout de fonctions pour gérer le vote

Ajoutons la fonction qui permet de commencer la session de vote :

function startVotingSession() public onlyOwner {
    workflowStatus = WorkflowStatus.VotingSessionStarted;

    emit WorkflowStatusChange(WorkflowStatus.ProposalsRegistrationEnded, workflowStatus);
}

Puis la fonction permettant de voter :

function vote(uint _proposalId) public {
    require(workflowStatus == WorkflowStatus.VotingSessionStarted, "Can't vote at this time.");
    require(voters[msg.sender].isRegistered, "Only registered voters can vote.");
    require(!voters[msg.sender].hasVoted, "The voter has already voted.");
    require(_proposalId < proposals.length, "Invalid proposal ID.");

    voters[msg.sender].hasVoted = true;
    voters[msg.sender].votedProposalId = _proposalId;

    proposals[_proposalId].voteCount++;

    emit Voted(msg.sender, _proposalId);
}

Ajoutons un évenement Voted au contrat :

event Voted (address voter, uint proposalId);

De nouveau, compilez le contrat pour vérifier que tout est ok.

Fonctionnement :

  • vote(uint _proposalId): Cette fonction est utilisée par un électeur enregistré pour voter pour une proposition. Elle vérifie d'abord que le contrat est actuellement en état VotingSessionStarted, que l'électeur est enregistré, n'a pas déjà voté et que l'ID de la proposition est valide. Si ces conditions sont remplies, la fonction enregistre le vote de l'électeur pour la proposition et émet un événement Voted.

6. Ajout de fonctions pour compter les votes et obtenir le gagnant

Ajoutons la fonction qui permet de fermer la session de vote :

function endVotingSession() public onlyOwner {
    workflowStatus = WorkflowStatus.VotesTallied;

    emit WorkflowStatusChange(WorkflowStatus.VotingSessionEnded, workflowStatus);
}

Ajoutons désormais la fonction permettant d'obtenir le gagnant :

function getWinner() public view returns (uint winningProposalId) {
    require(workflowStatus == WorkflowStatus.VotesTallied, "Can't get the winner at this time.");

    uint winningVoteCount = 0;
    for (uint i = 0; i < proposals.length; i++) {
        if (proposals[i].voteCount > winningVoteCount) {
            winningVoteCount = proposals[i].voteCount;
            winningProposalId = i;
        }
    }
}

Compilez de nouveau npx hardhat compile pour vérifier qu'il n'y a aucune erreur.

Fonctionnement :

  • endVotingSession(): Cette fonction est utilisée par l'administrateur pour terminer la session de vote. Elle change l'état du contrat à VotesTallied (votes comptabilisés) et émet un événement WorkflowStatusChange pour notifier du changement d'état.
  • getWinner(): Cette fonction est utilisée pour obtenir l'ID de la proposition gagnante. Elle vérifie d'abord que le contrat est actuellement en état VotesTallied. Si cette condition est remplie, la fonction parcourt la liste des propositions et renvoie l'ID de la proposition qui a reçu le plus grand nombre de votes.



A ce stade, nous devrions respecter l'ensemble des critères indiqué dans l'énoncé :

  1. Les électeurs sont ajoutés à une liste blanche par l'administrateur (la fonction registerVoter).
  2. L'administrateur peut démarrer et terminer la session d'enregistrement des propositions (la fonction startProposalsRegistration).
  3. Les électeurs inscrits peuvent soumettre leurs propositions pendant que la session d'enregistrement est active (la fonction registerProposal).
  4. L'administrateur peut lancer et terminer la session de vote (les fonctions startVotingSession et endVotingSession).
  5. Les électeurs inscrits peuvent voter pour leur proposition préférée (la fonction vote).
  6. L'administrateur peut comptabiliser les votes (la fonction endVotingSession et l'état VotesTallied).
  7. Tout le monde peut vérifier les derniers détails de la proposition gagnante (la fonction getWinner).

Aussi le contrat a les éléments suivants :

  • Nous utilisons la dernière version de solidity actuellement supporté par Hardhat: 0.8.18 (en date du 27/05/2023)
  • L'administrateur est celui qui déploie le contrat intelligent (grâce à l'importation et à l'utilisation de Ownable d'OpenZeppelin).
  • Il définit les structures de données Voter et Proposal.
  • Il définit l'énumération WorkflowStatus pour gérer les différents états d'un vote.
  • Il définit une fonction getWinner pour obtenir le gagnant.
  • Il définit et émet les événements VoterRegistered, WorkflowStatusChange, ProposalRegistered, et Voted.

Coté sécurité, nous utilisons déjà onlyOwner, nous pourrions aussi ajouter un modificateur onlyRegisteredVoter qui n'autoriserait que les électeurs enregistrés à voter et proposer. (nous n'allons pas le faire ici).

Voici à ce stade le code complet du contrat de vote :

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

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

contract Voting is Ownable {
    mapping(address => Voter) public voters;
    Proposal[] public proposals;
    WorkflowStatus public workflowStatus;

    struct Voter {
        bool isRegistered;
        bool hasVoted;
        uint votedProposalId;
    }

    struct Proposal {
        string description;
        uint voteCount;
    }

    enum WorkflowStatus {
        RegisteringVoters,
        ProposalsRegistrationStarted,
        ProposalsRegistrationEnded,
        VotingSessionStarted,
        VotingSessionEnded,
        VotesTallied
    }

    event VoterRegistered(address voterAddress);
    event WorkflowStatusChange(
        WorkflowStatus previousStatus,
        WorkflowStatus newStatus
    );
    event ProposalRegistered(uint proposalId);
    event Voted(address voter, uint proposalId);

    function registerVoter(address _voterAddress) public onlyOwner {
        require(
            workflowStatus == WorkflowStatus.RegisteringVoters,
            "Can't register voters at this time."
        );
        require(
            !voters[_voterAddress].isRegistered,
            "The voter is already registered."
        );

        voters[_voterAddress] = Voter({
            isRegistered: true,
            hasVoted: false,
            votedProposalId: 0
        });

        emit VoterRegistered(_voterAddress);
    }

    function startProposalsRegistration() public onlyOwner {
        workflowStatus = WorkflowStatus.ProposalsRegistrationStarted;

        emit WorkflowStatusChange(
            WorkflowStatus.RegisteringVoters,
            workflowStatus
        );
    }

    function registerProposal(string memory _description) public {
        require(
            workflowStatus == WorkflowStatus.ProposalsRegistrationStarted,
            "Can't register proposals at this time."
        );
        require(
            voters[msg.sender].isRegistered,
            "Only registered voters can submit proposals."
        );

        proposals.push(Proposal({description: _description, voteCount: 0}));

        emit ProposalRegistered(proposals.length - 1);
    }

    function startVotingSession() public onlyOwner {
        workflowStatus = WorkflowStatus.VotingSessionStarted;

        emit WorkflowStatusChange(
            WorkflowStatus.ProposalsRegistrationEnded,
            workflowStatus
        );
    }

    function vote(uint _proposalId) public {
        require(
            workflowStatus == WorkflowStatus.VotingSessionStarted,
            "Can't vote at this time."
        );
        require(
            voters[msg.sender].isRegistered,
            "Only registered voters can vote."
        );
        require(!voters[msg.sender].hasVoted, "The voter has already voted.");
        require(_proposalId < proposals.length, "Invalid proposal ID.");

        voters[msg.sender].hasVoted = true;
        voters[msg.sender].votedProposalId = _proposalId;

        proposals[_proposalId].voteCount++;

        emit Voted(msg.sender, _proposalId);
    }

    function endVotingSession() public onlyOwner {
        workflowStatus = WorkflowStatus.VotesTallied;

        emit WorkflowStatusChange(
            WorkflowStatus.VotingSessionEnded,
            workflowStatus
        );
    }

    function getWinner() public view returns (uint winningProposalId) {
        require(
            workflowStatus == WorkflowStatus.VotesTallied,
            "Can't get the winner at this time."
        );

        uint winningVoteCount = 0;
        for (uint i = 0; i < proposals.length; i++) {
            if (proposals[i].voteCount > winningVoteCount) {
                winningVoteCount = proposals[i].voteCount;
                winningProposalId = i;
            }
        }
    }
}

7. Déploiement du contrat sur SEPOLIA, le réseau de test de Ethereum via Remix IDE.


Étape 1: Configurer Remix IDE

  1. Accédez à Remix IDE en suivant ce lien : https://remix.ethereum.org.
  2. Copiez votre code source Solidity dans l'éditeur.

Étape 2: Compiler le contrat

  1. Sélectionnez l'onglet "Solidity Compiler" dans le panneau de gauche.
  2. Assurez-vous que la version du compilateur est la même que celle spécifiée dans le pragma de votre contrat.
  3. Cliquez sur "Compile".

Étape 3: Connecter MetaMask

  1. Assurez-vous que votre compte MetaMask est connecté à Remix.
  2. Choisissez le réseau de test SEPOLIA.

Étape 4: Déployer le contrat

  1. Sélectionnez l'onglet "Deploy & Run Transactions" dans le panneau de gauche.
  2. Dans l'onglet "Environment", sélectionnez "Injected Web3". Cela utilisera la connexion de votre MetaMask.
  3. Assurez-vous que MetaMask est connecté à SEPOLIA. Si ce n'est pas le cas, vous devrez ajouter les informations du réseau SEPOLIA dans MetaMask. Vous pouvez les trouver ici.
  4. Assurez-vous que l'adresse affichée dans le champ "Account" est celle de votre compte MetaMask.
  5. Dans le champ "Contract", choisissez le contrat que vous voulez déployer.
  6. Cliquez sur le bouton "Deploy".

Une fois que vous aurez cliqué sur "Deploy", MetaMask ouvrira une fenêtre vous demandant de confirmer la transaction. Après avoir confirmé la transaction, votre contrat sera déployé sur le réseau de test SEPOLIA.

Pour récupérer l'adresse du contrat (cela sera nécessaire pour le développement de notre dApp), procédez ainsi :

  1. Une fois le contrat déployé avec succès, vous verrez un nouveau bloc dans la section "Deployed Contracts" de l'onglet "Deploy & Run Transactions" dans Remix IDE.
  2. Dans ce bloc, vous verrez le nom de votre contrat avec une adresse à côté de lui. C'est l'adresse Ethereum de votre contrat déployé.
  3. Cliquez sur le bouton copier à côté de l'adresse pour copier l'adresse du contrat dans le presse-papiers.

Cette adresse sera utilisée dans votre application décentralisée (dApp) pour interagir avec le contrat déployé. Vous pouvez créer un fichier (par exemple contract-address.json) dans votre projet dApp qui exporte cette adresse, et l'importer ensuite où vous en avez besoin dans votre code.

Exemple de contract-address.json :

{
    "address": "0x123...abc"  // L'adresse de votre contrat déployé
}

Pour l'importer dans notre future dApp écrite en React, nous pourrons faire de cette manière :

import contractAddress from './contract-address.json';

// Utiliser contractAddress.address pour obtenir l'adresse du contrat


Remarque: Vous aurez besoin d'ETH sur le réseau de test SEPOLIA pour payer le gaz nécessaire à la transaction. Vous pouvez obtenir de l'ETH de test gratuitement à partir d'un faucet. Par exemple ici.

Voyez ce projet comme quelque chose de potentiellement utilisable en entreprise pour apporter de la transparence 🔎 et, par conséquent, de la confiance lors de la soumission de résolutions et de votes📩: toutes les actions sont visibles à travers le "Grand Livre"📖  , autrement dit, la blockchain. Ainsi, toute éventuelle manipulation des votes ne pourrait pas avoir lieu puisqu'elle est visible par tous. De plus, bien que traçable, un wallet n'est pas forcément nominatif, ce qui permet de respecter le RGPD dans le cas de votes pour des élections CSE, par exemple...

Création de la dApp

Dans un autre post, nous allons nous occuper de la création de la dApp afin de pouvoir interagir avec le contrat.

N'oubliez pas de liker et reposter si vous avez aimé ce post !

Inscrivez-vous pour être informé de toutes les sorties d'articles, dont la partie 2 de celui-ci. A bientôt !

Mots clés