Solidity par l’exemple

Contrat de Vote

Le contrat suivant est assez complexe, mais met en valeur de nombreuses fonctionnalités de Solidity. Il met en place un Contrat de Vote. Bien sûr, les principaux problèmes du vote électronique est la façon d’attribuer des droits de vote au bon personnes et comment prévenir la manipulation. Nous n’allons pas résoudre tous les problèmes ici, mais au moins nous montrerons comment le vote délégué peut être fait pour que le décompte des voix est automatique et complètement transparent en même temps.

L’idée est de créer un contrat par scrutin, fournissant un nom court pour chaque option. Ensuite, le créateur du contrat qui sert de président donnera le droit de vote à chacun adresse individuellement.

Les personnes derrière les adresses peuvent alors choisir de voter eux-mêmes ou de déléguer leur vote pour une personne de confiance.

A la fin du temps de vote, winningProposal() renverra la proposition avec le plus grand nombre de suffrages.

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
/// @title Vote avec délégation.
contract Ballot {
    // Ceci déclare un nouveau type complexe qui va
    // être utilisé pour les variables plus tard.
    // Il représentera un seul électeur.
    struct Voter {
        uint weight; // le poids est cumulé par délégation
        bool voted;  // si vrai, cette personne a déjà voté
        address delegate; // personne déléguée à
        uint vote;   // index de la proposition votée
    }

    // Il s'agit d'un type pour une seule proposition.
    struct Proposal {
        bytes32 name;   // nom court (jusqu'à 32 octets)
        uint voteCount; // nombre de votes cumulés
    }

    address public chairperson;

    // Ceci déclare une variable d'état qui
    // stocke une structure `Voter` pour chaque adresse possible.
    mapping(address => Voter) public voters;

    // Un tableau de taille dynamique de structures `Proposal`.
    Proposal[] public proposals;

    /// Créez un nouveau bulletin de vote pour choisir l'un des `proposalNames`.
    constructor(bytes32[] memory proposalNames) {
        chairperson = msg.sender;
        voters[chairperson].weight = 1;

        // Pour chacun des noms de proposition fournis,
        // crée un nouvel objet de proposition et l'ajoute
        // à la fin du tableau.
        for (uint i = 0; i < proposalNames.length; i++) {
            // `Proposal({...})` crée un temporaire
            // Objet de proposition et `proposals.push(...)`
            // l'ajoute à la fin de `proposals`.
            proposals.push(Proposal({
                name: proposalNames[i],
                voteCount: 0
            }));
        }
    }

    // Donne à `voter` le droit de voter sur ce bulletin de vote.
    // Ne peut être appelé que par `chairperson`.
    function giveRightToVote(address voter) external {
        // Si le premier argument de `require` est
        // `false`, l'exécution se termine et tout
        // modifications de l'état et des soldes Ether
        // sont annulés.
        // Cela consommait tout le gaz dans les anciennes versions d'EVM, mais
        // plus maintenant.
        // C'est souvent une bonne idée d'utiliser `require` pour vérifier si
        // les fonctions sont appelées correctement.
        // Comme deuxième argument, vous pouvez également fournir un
        // explication de ce qui s'est mal passé.
        require(
            msg.sender == chairperson,
            "Seul le président peut donner droit de vote."
        );
        require(
            !voters[voter].voted,
            "L'électeur a déjà voté."
        );
        require(voters[voter].weight == 0);
        voters[voter].weight = 1;
    }

    /// Déléguez votre vote au votant `to`.
    function delegate(address to) external {
        // attribue une référence
        Voter storage sender = voters[msg.sender];

        require(!sender.voted, "Vous avez déjà voté.");
        require(to != msg.sender, "L'autodélégation est interdite.");

        // Transférer la délégation tant que
        // `to` également délégué.
        // En général, de telles boucles sont très dangereuses,
        // parce que s'ils tournent trop longtemps, ils pourraient
        // qvoir besoin de plus de gaz que ce qui est disponible dans un bloc.
        // Dans ce cas, la délégation ne sera pas exécutée,
        // mais dans d'autres situations, de telles boucles pourraient
        // provoquer le "blocage" complet d'un contrat.
        while (voters[to].delegate != address(0)) {
            to = voters[to].delegate;

            // Nous avons trouvé une boucle dans la délégation, non autorisée.
            require(to != msg.sender, "Found loop in delegation.");
        }

        // Puisque `sender` est une référence, cela
        // modifie `voters[msg.sender].voted`
        sender.voted = true;
        sender.delegate = to;
        Voter storage delegate_ = voters[to];
        if (delegate_.voted) {
            // Si le délégué a déjà voté,
            // ajouter directement au nombre de votes
            proposals[delegate_.vote].voteCount += sender.weight;
        } else {
            // Si le délégué n'a pas encore voté,
            // ajoute à son poids.
            delegate_.weight += sender.weight;
        }
    }

    /// Donnez votre vote (y compris les votes qui vous sont délégués)
    /// à la proposition `propositions[proposition].nom`.
    function vote(uint proposal) external {
        Voter storage sender = voters[msg.sender];

        require(sender.weight != 0, "N'a pas le droit de voter");
        require(!sender.voted, "Déjà voté.");

        sender.voted = true;
        sender.vote = proposal;

        // Si `proposal` est hors de la plage du tableau,
        // cela lancera automatiquement et annulera tout
        // changements.
        proposals[proposal].voteCount += sender.weight;
    }

    /// @dev Calcule la proposition gagnante en prenant tous
    /// les votes précédents en compte.
    function winningProposal() public view
            returns (uint winningProposal_)
    {
        uint winningVoteCount = 0;
        for (uint p = 0; p < proposals.length; p++) {
            if (proposals[p].voteCount > winningVoteCount) {
                winningVoteCount = proposals[p].voteCount;
                winningProposal_ = p;
            }
        }
    }

    // Appelle la fonction winProposal() pour obtenir l'index
    // du gagnant contenu dans le tableau de propositions puis
    // renvoie le nom du gagnant
    function winnerName() external view
            returns (bytes32 winnerName_)
    {
        winnerName_ = proposals[winningProposal()].name;
    }
}

Améliorations possibles

Actuellement, de nombreuses transactions sont nécessaires pour céder les droits de voter à tous les participants. Pouvez-vous penser à une meilleure façon?

Enchères à l’aveugle

Dans cette section, nous allons montrer à quel point il est facile de créer un smart contrat d’enchères sur Ethereum. Nous allons commencer par un contrat d’enchère où tout le monde peut voir les offres qui sont faites, puis nous étendrons ce contrat pour des d’enchères à aveugles où il n’est pas possible de voir l’offre réelle jusqu’à la fin de la période d’enchères.

Simple Enchères

L’idée générale d’une enchères est que chacun peut envoyer ses offres pendant une période d’enchères. Les offres doivent comprendre avec l’envoi un certain nombre Ether pour valider leur enchère. Si l’offre la plus élevée est augmentée, le précédent enchérisseur le plus élevé récupère son argent. Après la fin de la période d’enchères, le contrat doit être appelé manuellement pour que le bénéficiaire reçoive son argent - les contrats ne peuvent pas s’activer eux-mêmes.

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
contract SimpleAuction {
    // Paramètres des enchères. Le temps est soit
    // un timestamp unix (nombres de secondes depuis le 01-01-1970)
    // ou une période en secondes.
    address payable public beneficiary;
    uint public auctionEndTime;

    // État actuel de l'enchère.
    address public highestBidder;
    uint public highestBid;

    // Listes de tous les enrichisseurs pouvant retirer leurs enchères
    mapping(address => uint) pendingReturns;

    // Définit sur `true` à la fin de l'enchère, pour refuser les changements
    // By default initialized to `false`.
    bool ended;

    // Evénements qui vont être émis lors des enchères (pour votre front-end, par exemple)
    event HighestBidIncreased(address bidder, uint amount);
    event AuctionEnded(address winner, uint amount);

    // Erreurs qui décrivent les potentielles problèmes rencontrés.

    // Les commentaires à triple barre oblique sont appelés commentaires natspec.
    // Ils seront affichés lorsque l'utilisateur
    // est invité à confirmer une transaction ou à confirmer une opération.
    // lorsqu'une erreur est affichée.

    /// L'enchère est terminée.
    error AuctionAlreadyEnded();
    /// Il existe déjà une offre supérieure ou égale.
    error BidNotHighEnough(uint highestBid);
    /// L'enchère n'est pas encore terminée.
    error AuctionNotYetEnded();
    /// La fonction auctionEnd a déjà été appelée.
    error AuctionEndAlreadyCalled();

    /// Créer une simple enchère avec `biddingTime`
    /// en secondes avant la fin de l'enchère (3600=1H)
    /// et `beneficiaryAddress` au nom de l'adresse l'auteur de l'enchère.
    constructor(
        uint biddingTime,
        address payable beneficiaryAddress
    ) {
        beneficiary = beneficiaryAddress;
        auctionEndTime = block.timestamp + biddingTime;
    }

    /// Enchérir sur l'enchère avec la valeur envoyée
    /// avec cette transaction.
    /// La valeur ne sera remboursée que si le
    /// l'enchère n'est pas gagnée.
    function bid() external payable {
        // Aucun argument n'est nécessaire, toutes
        // informations fait déjà partie de
        // la transaction. Le mot clé "payable"
        // est requis pour que la fonction
        // puisse recevoir Ether.

        // Renvoie (revert) l'appel si la pédiode
        // de l'enchère est terminée.
        if (block.timestamp > auctionEndTime)
            revert AuctionAlreadyEnded();

        // Si l'enchère n'est pas plus élevée, le
        // remboursement est envoyé
        // ("revert" annulera tous les changements  incluant
        // l'argent reçu, qui sera automatiquement renvoyer au propriétaire).
        if (msg.value <= highestBid)
            revert BidNotHighEnough(highestBid);

        if (highestBid != 0) {
            // Renvoyer l'argent en utilisant simplement
            // "mostbidder.send(highestBid)" est un risque de sécurité
            // car il ça pourrait exécuter un contrat non fiable.
            // Il est toujours plus sûr de laisser les destinataires
            // retirer leur argent eux-mêmes.
            pendingReturns[highestBidder] += highestBid;
        }
        highestBidder = msg.sender;
        highestBid = msg.value;
        emit HighestBidIncreased(msg.sender, msg.value);
    }

    /// Retirer une enchère qui a été surenchérie.
    function withdraw() external returns (bool) {
        uint amount = pendingReturns[msg.sender];
        if (amount > 0) {
            // Il est important de remettre à zéro l'enchère du destinataire
            // car il peut rappeler cette fonction et récupérer un seconde fois sont enchère
            // puis une troisième, quatrième fois...
            pendingReturns[msg.sender] = 0;

            // msg.sender n'est pas de type `address payable` mais il le doit
            // du type adresse payable (pour dire à solidity qu'il peut envoyer de l'argent dessus
            // grâce à `send()`)
            // La convertion `address` -> `address payable` peut se faire grâce
            // à `payable(msg.sender)`
            if (!payable(msg.sender).send(amount)) {
                // Si la tx ne s'execute pas:
                // Pas besoin de renvoyer une erreur ici, remettez juste l'argent à l'encherriseur,
                // il pourra revenir plus tard
                pendingReturns[msg.sender] = amount;
                return false;
            }
        }
        return true;
    }

    /// Terminez l'enchère et envoyez l'offre la plus élevée
    /// au bénéficiaire.
    function auctionEnd() external {
        // C'est un bon guide pour structurer les fonctions qui interagissent
        // avec d'autres contrats (c'est-à-dire qu'ils appellent des fonctions ou envoient de l'Ether)
        // en trois phases :
        // 1. conditions de vérification
        // 2. effectuer des actions
        // 3. interaction avec d'autres contrats
        // Si ces phases sont mélangées, d'autre contrat pourrait
        // modifier l'état ou
        // prendre des actions (paiement d'éther) à effectuer plusieurs fois.
        // Si les fonctions appelées en interne incluent l'interaction avec des
        // contrats, ils doivent également être considérés comme une interaction avec
        // des contrats externes.

        // 1. Conditions
        if (block.timestamp < auctionEndTime)
            revert AuctionNotYetEnded();
        if (ended)
            revert AuctionEndAlreadyCalled();

        // 2. Effets
        ended = true;
        emit AuctionEnded(highestBidder, highestBid);

        // 3. Interactions
        beneficiary.transfer(highestBid);
    }
}

Blind Auction

Nous allons maintenant étendre ce contract à une enchère à l’aveugle. L’avantage d’une enchère à l’aveugle c’est qu’il n’y a pas de pression temporelle vers la fin de la période d’enchère. La création d’une enchère à l’aveugle sur une plateforme transparente peut sembler contradictoire, mais la cryptographie vient à la rescousse.

Pendant la période d’enchère, un enchérisseur n’envoie pas réellement son offre, mais seulement une version hachée de celle-ci. Étant donné qu’il est actuellement considéré comme pratiquement impossible de trouver deux valeurs (suffisamment longues) dont les hash sont égales, l’enchérisseur s’engage à faire son offre par ce biais. À la fin de la période d’enchères, les enchérisseurs doivent révéler leurs offres : Ils envoient leurs valeurs non cryptées et le contrat vérifie que le hash est le même que celui fournie pendant la période d’enchères.

Un autre défi est de savoir comment rendre l’enchère liante et aveugle en même temps. La seule façon d’empêcher l’enchérisseur de ne pas envoyer l’argent après avoir remporté l’enchère après avoir remporté l’enchère est de l’obliger à l’envoyer en même temps que l’offre. Puisque les transferts de valeur ne peuvent pas être censurée dans Ethereum, tout le monde peut voir leur valeur.

Le contrat suivant résout ce problème en acceptant toute valeur qui est supérieure à l’offre la plus élevée. Puisque cela ne peut bien sûr être vérifié que pendant la phase de révélation, certaines offres peuvent être invalides, et c’est voulu (il y a même un drapeau explicite pour placer des offres invalides avec des transferts de grande valeur) : Les enchérisseurs peuvent confondre la concurrence en plaçant plusieurs offres non valides, hautes ou basses.

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
contract BlindAuction {
    struct Bid {
        bytes32 blindedBid;
        uint deposit;
    }

    address payable public beneficiary;
    uint public biddingEnd;
    uint public revealEnd;
    bool public ended;

    mapping(address => Bid[]) public bids;

    address public highestBidder;
    uint public highestBid;

    // Permettre le retrait des offres précédentes
    mapping(address => uint) pendingReturns;

    event AuctionEnded(address winner, uint highestBid);

    // Erreurs qui décrivent des échecs.

    /// La fonction a été appelée trop tôt.
    /// Essayez à nouveau à `time`.
    error TooEarly(uint time);
    /// La fonction a été appelée trop tard.
    /// Elle ne peut pas être appelée après `time`.
    error TooLate(uint time);
    /// La fonction auctionEnd a déjà été appelée.
    error AuctionEndAlreadyCalled();

    // Les modificateurs sont un moyen pratique de valider les entrées de
    // fonctions. `onlyBefore` est appliqué à `bid` ci-dessous :
    // Le nouveau corps de la fonction est le corps du modificateur où
    // `_` est remplacé par l'ancien corps de la fonction.
    modifier onlyBefore(uint time) {
        if (block.timestamp >= time) revert TooLate(time);
        _;
    }
    modifier onlyAfter(uint time) {
        if (block.timestamp <= time) revert TooEarly(time);
        _;
    }

    constructor(
        uint biddingTime,
        uint revealTime,
        address payable beneficiaryAddress
    ) {
        beneficiary = beneficiaryAddress;
        biddingEnd = block.timestamp + biddingTime;
        revealEnd = biddingEnd + revealTime;
    }

    /// Placez une enchère aveugle avec `blindedBid` =
    /// keccak256(abi.encodePacked(value, fake, secret)).
    /// L'éther envoyé n'est remboursé que si l'offre est
    /// correctement révélée lors de la phase de révélation. L'offre est valide si
    /// l'éther envoyé avec l'offre est au moins égal à "value" et que
    /// "fake" n'est pas vrai. Mettre "fake" à true et ne pas envoyer
    /// le montant exact sont des moyens de cacher la véritable enchère mais
    /// tout en effectuant le dépôt requis. Une même adresse peut
    /// placer plusieurs enchères.
    function bid(bytes32 blindedBid)
        external
        payable
        onlyBefore(biddingEnd)
    {
        bids[msg.sender].push(Bid({
            blindedBid: blindedBid,
            deposit: msg.value
        }));
    }

    /// Révélez vos enchères aveugles. Vous obtiendrez un remboursement pour toutes
    /// les offres invalides correctement masquées et pour toutes les offres sauf pour
    /// l'enchère la plus élevée.
    function reveal(
        uint[] calldata values,
        bool[] calldata fakes,
        bytes32[] calldata secrets
    )
        external
        onlyAfter(biddingEnd)
        onlyBefore(revealEnd)
    {
        uint length = bids[msg.sender].length;
        require(values.length == length);
        require(fakes.length == length);
        require(secrets.length == length);

        uint refund;
        for (uint i = 0; i < length; i++) {
            Bid storage bidToCheck = bids[msg.sender][i];
            (uint value, bool fake, bytes32 secret) =
                    (values[i], fakes[i], secrets[i]);
            if (bidToCheck.blindedBid != keccak256(abi.encodePacked(value, fake, secret))) {
                // L'enchère n'a pas été réellement révélée.
                // Ne pas rembourser le dépôt.
                continue;
            }
            refund += bidToCheck.deposit;
            if (!fake && bidToCheck.deposit >= value) {
                if (placeBid(msg.sender, value))
                    refund -= value;
            }
            // Rendre impossible pour l'expéditeur de réclamer à nouveau
            // le même dépôt.
            bidToCheck.blindedBid = bytes32(0);
        }
        payable(msg.sender).transfer(refund);
    }

    /// Retirer une offre qui a été surenchérie.
    function withdraw() external {
        uint amount = pendingReturns[msg.sender];
        if (amount > 0) {
            // Il est important de mettre cette valeur à zéro car le destinataire
            // peut appeler cette fonction à nouveau dans le cadre de l'appel de réception
            // avant que `transfer` ne revienne (voir la remarque ci-dessus à propos des
            // conditions -> effets -> interaction).
            pendingReturns[msg.sender] = 0;

            payable(msg.sender).transfer(amount);
        }
    }

    /// Mettre fin à l'enchère et envoyer l'offre la plus élevée
    /// au bénéficiaire.
    function auctionEnd()
        external
        onlyAfter(revealEnd)
    {
        if (ended) revert AuctionEndAlreadyCalled();
        emit AuctionEnded(highestBidder, highestBid);
        ended = true;
        beneficiary.transfer(highestBid);
    }

    // Il s'agit d'une fonction "interne", ce qui signifie qu'elle
    // ne peut être appelée qu'à partir du contrat lui-même (ou à partir de
    // contrats dérivés).
    function placeBid(address bidder, uint value) internal
            returns (bool success)
    {
        if (value <= highestBid) {
            return false;
        }
        if (highestBidder != address(0)) {
            // Refund the previously highest bidder.
            pendingReturns[highestBidder] += highestBid;
        }
        highestBid = value;
        highestBidder = bidder;
        return true;
    }
}

Achat à distance sécurisé

L’achat de biens à distance nécessite actuellement plusieurs parties qui doivent se faire confiance. La configuration la plus simple implique un vendeur et un acheteur. L’acheteur souhaite recevoir un article du vendeur et le vendeur souhaite obtenir de l’argent (ou un équivalent) en retour. La partie problématique est l’expédition ici : il n’y a aucun moyen de déterminer pour sûr que l’article est arrivé à l’acheteur.

Il existe plusieurs façons de résoudre ce problème, mais toutes échouent d’une manière ou d’une autre. Dans l’exemple suivant, les deux parties doivent mettre deux fois la valeur de l’article dans le contrat en tant qu’entiercement. Dès que cela s’est produit, l’argent restera enfermé à l’intérieur le contrat jusqu’à ce que l’acheteur confirme qu’il a bien reçu l’objet. Après ça, l’acheteur reçoit la valeur (la moitié de son acompte) et le vendeur reçoit trois fois la valeur (leur dépôt plus la valeur). L’idée derrière c’est que les deux parties ont une incitation à résoudre la situation ou autrement leur argent est verrouillé pour toujours.

Bien entendu, ce contrat ne résout pas le problème, mais donne un aperçu de la manière dont vous pouvez utiliser des constructions de type machine d’état dans un contrat.

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
contract Purchase {
    uint public value;
    address payable public seller;
    address payable public buyer;

    enum State { Created, Locked, Release, Inactive }
    // La variable d'état a une valeur par défaut du premier membre, `State.created`
    State public state;

    modifier condition(bool condition_) {
        require(condition_);
        _;
    }

    /// Seul l'acheteur peut appeler cette fonction.
    error OnlyBuyer();
    /// Seul le vendeur peut appeler cette fonction.
    error OnlySeller();
    /// La fonction ne peut pas être appelée à l'état actuel.
    error InvalidState();
    /// La valeur fournie doit être paire.
    error ValueNotEven();

    modifier onlyBuyer() {
        if (msg.sender != buyer)
            revert OnlyBuyer();
        _;
    }

    modifier onlySeller() {
        if (msg.sender != seller)
            revert OnlySeller();
        _;
    }

    modifier inState(State state_) {
        if (state != state_)
            revert InvalidState();
        _;
    }

    event Aborted();
    event PurchaseConfirmed();
    event ItemReceived();
    event SellerRefunded();

    // Assurez-vous que `msg.value` est un nombre pair.
    // La division sera tronquée si c'est un nombre impair.
    // Vérifie par multiplication qu'il ne s'agit pas d'un nombre impair.
    constructor() payable {
        seller = payable(msg.sender);
        value = msg.value / 2;
        if ((2 * value) != msg.value)
            revert ValueNotEven();
    }

    /// Abandonnez l'achat et récupérez l'éther.
    /// Ne peut être appelé que par le vendeur avant
    /// le contrat est verrouillé.
    function abort()
        external
        onlySeller
        inState(State.Created)
    {
        emit Aborted();
        state = State.Inactive;
        // Nous utilisons directement le transfert ici. Il est
        // anti-réentrance, car c'est le
        // dernier appel dans cette fonction et nous
        // a déjà changé l'état.
        seller.transfer(address(this).balance);
    }

    /// Confirmez l'achat en tant qu'acheteur.
    /// La transaction doit inclure `2 * value` ether.
    /// L'éther sera verrouillé jusqu'à confirmationReceived
    /// soit appelé.
    function confirmPurchase()
        external
        inState(State.Created)
        condition(msg.value == (2 * value))
        payable
    {
        emit PurchaseConfirmed();
        buyer = payable(msg.sender);
        state = State.Locked;
    }

    /// Confirmez que vous (l'acheteur) avez reçu l'article.
    /// Cela libérera l'éther verrouillé.
    function confirmReceived()
        external
        onlyBuyer
        inState(State.Locked)
    {
        emit ItemReceived();
        // Il est important de changer d'abord l'état car
        // sinon, les contrats appelés en utilisant `send` ci-dessous
        // peut rappeler ici.
        state = State.Release;

        buyer.transfer(value);
    }

    /// Cette fonction rembourse le vendeur, c'est-à-dire
    /// rembourse les fonds bloqués du vendeur.
    function refundSeller()
        external
        onlySeller
        inState(State.Release)
    {
        emit SellerRefunded();
        // Il est important de changer d'abord l'état car
        // sinon, les contrats appelés en utilisant `send` ci-dessous
        // peut rappeler ici.
        state = State.Inactive;

        seller.transfer(3 * value);
    }
}

Canal de micropaiement

Dans cette section, nous allons apprendre à construire un exemple d’implémentation d’un canal de paiement. Il utilisera des signatures cryptographiques pour faire des transferts répétés d’Ether entre les mêmes parties sécurisés, instantanés et sans frais de transaction. Pour l’exemple, nous devons comprendre comment signer et vérifier les signatures, et configurer le canal de paiement.

Création et vérification de signatures

Imaginez qu’Alice veuille envoyer de l’éther à Bob, c’est-à-dire Alice est l’expéditeur et Bob est le destinataire.

Alice n’a besoin que d’envoyer des messages signés cryptographiquement off-chain (exemple: par e-mail) à Bob et c’est similaire à la rédaction de chèques.

Alice et Bob utilisent des signatures pour autoriser les transactions, ce qui est possible avec les Smart Contract d’Ethereum. Alice construira un simple Smart Contract qui lui permettra de transmettre Ether, mais au lieu d’appeler elle-même une fonction pour initier un paiement, elle laissera Bob le faire, qui paiera donc les frais de transaction.

Le contrat fonctionnera comme ça:

  1. Alice déploie le contrat ReceiverPays, avec suffisamment d’Ether pour couvrir les paiements qui seront effectués.

  2. Alice autorise un paiement en signant un message avec sa clé privée.

  3. Alice envoie le message signé cryptographiquement à Bob. Le message n’a pas besoin d’être gardé secret

    (expliqué plus loin), et le mécanisme pour l’envoyer n’a pas d’importance.

  4. Bob réclame son paiement en présentant le message signé au smart contract, celui-ci vérifie le

    l’authenticité du message, puis débloque les fonds.

Création de la signature:

Alice n’a pas besoin d’interagir avec le réseau Ethereum pour signer la transaction, le processus est complètement hors ligne. Dans ce tutoriel, nous allons signer des messages dans le navigateur en utilisant web3.js et MetaMask, avec la methode decrite dans l”EIP-712, car il offre un certain nombre d’autres avantages en matière de sécurité.

/// Hasher en premier, va nous facilité les choses
const hash = web3.utils.sha3("message to sign");
web3.eth.personal.sign(hash, web3.eth.defaultAccount, function () { console.log("Signed"); });

Note

Le web3.eth.personal.sign ajoute la longueur du message aux données signées. Puisque nous hachons d’abord, le message fera toujours exactement 32 octets, et donc cette longueur le préfixe est toujours le même.

Quoi signer ?

Pour qu’un contrat exécute des paiements, le message signé doit inclure :

  1. L’adresse du destinataire.

  2. Le montant à transférer.

  3. Protection contre les attaques par rejeu (replay attacks in English).

*Une attaque par rejeu se produit lorsqu’un message signé est réutilisé pour réclamer une seconde fois l’autorisation de la même action (exemple: réenvoyer le même montant d’Eth). Pour éviter ces attaques nous utilisons la même technique que dans les transactions Ethereum elles-mêmes, le fameux nonce, qui est le nombre de transactions envoyées par un compte. Le Smart Contract vérifie si le nonce est utilisé plusieurs fois.

Un autre type d’attaque par rejeu peut se produire lorsque le propriétaire déploie un Smart Contract ReceiverPays, fait quelques paiements, puis détruit le contrat. Plus tard, ils décident de déployer à nouveau le Smart Contract RecipientPays, mais le nouveau contrat ne connaît pas les nonces utilisés dans le précédent déploiement, donc les attaquants peuvent à nouveau utiliser les anciens messages.

Alice peut se protéger contre cette attaque en incluant le l’adresse du contrat dans le message, et seuls les messages contenant l’adresse du contrat seront acceptés. Tu peux trouver un exemple de ceci dans les deux premières lignes de la fonction claimPayment() du contrat complet à la fin de cette section.

Packing arguments

Maintenant que nous avons identifié les informations à inclure dans le message signé, nous sommes prêts à construire le message, à le hacher et à le signer. Par question de simplicité, nous concaténons les données. Le ethereumjs-abi fournit une fonction appelée soliditySHA3 qui imite le comportement de la fonction keccak256 de Solidity en appliquant aux arguments encodés la fonction abi.encodePacked. Voici une fonction JavaScript qui crée la bonne signature pour l’exemple ReceiverPays :

// le "recipient" est l'adresse qui doit être payée.
// Le "amount" est en wei et spécifie la quantité d'éther à envoyer.
// "nonce" peut être n'importe quel nombre unique pour empêcher les attaques par rejeu
// "contractAddress" est utilisé pour empêcher les attaques de relecture de contrats croisés
function signPayment(recipient, amount, nonce, contractAddress, callback) {
    var hash = "0x" + abi.soliditySHA3(
        ["address", "uint256", "uint256", "address"],
        [recipient, amount, nonce, contractAddress]
    ).toString("hex");

    web3.eth.personal.sign(hash, web3.eth.defaultAccount, callback);
}

Récupération du signataire du message dans Solidity

En général, les signatures ECDSA se composent de deux paramètres, r et s. Les signatures dans Ethereum incluent un troisième paramètre appelé v, que vous pouvez utiliser pour vérifier quel clé privée du compte a été utilisée pour signer le message, et l’expéditeur de la transaction. Solidity fournit une fonction ecrecover qui accepte un message avec les paramètres r, s et v et renvoie l’adresse qui a été utilisée pour signer le message.

Extraction des paramètres de signature

Les signatures produites par web3.js sont la concaténation de r, s et v, la première étape consiste donc à diviser ces paramètres à part. Vous pouvez le faire côté client, mais le faire à l’intérieur le Smart Contract signifie que vous n’avez besoin d’envoyer qu’un seule paramètre signature plutôt que trois. Séparer un Array d’octets en ses parties constituantes est un gâchis, nous utilisons donc inline assembly pour faire le travail dans la fonction splitSignature (la troisième fonction dans le contrat complet à la fin de cette section).

Haché le message

Le Smart Contract doit savoir exactement quels paramètres ont été signés, et donc il doit recréer le message à partir des paramètres et l’utiliser pour la vérification de la signature. Les fonctions prefixed et recoverSigner le font dans la fonction claimPayment.

Le contrat complet

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract ReceiverPays {
    address owner = msg.sender;

    mapping(uint256 => bool) usedNonces;

    constructor() payable {}

    function claimPayment(uint256 amount, uint256 nonce, bytes memory signature) external {
        require(!usedNonces[nonce]);
        usedNonces[nonce] = true;

        // ceci recrée le message qui a été signé sur le client
        bytes32 message = prefixed(keccak256(abi.encodePacked(msg.sender, amount, nonce, this)));

        require(recoverSigner(message, signature) == owner);

        payable(msg.sender).transfer(amount);
    }

    /// détruit le contrat et récupére les fonds restants.
    function shutdown() external {
        require(msg.sender == owner);
        selfdestruct(payable(msg.sender));
    }

    /// La method de signature.
    function splitSignature(bytes memory sig)
        internal
        pure
        returns (uint8 v, bytes32 r, bytes32 s)
    {
        require(sig.length == 65);

        assembly {
            // 32 premiers octets, après le préfixe de longueur.
            r := mload(add(sig, 32))
            // 32 octets suivant.
            s := mload(add(sig, 64))
            // Derrniers octets (premier octet des 32 octets suivants).
            v := byte(0, mload(add(sig, 96)))
        }

        return (v, r, s);
    }

    function recoverSigner(bytes32 message, bytes memory sig)
        internal
        pure
        returns (address)
    {
        (uint8 v, bytes32 r, bytes32 s) = splitSignature(sig);

        return ecrecover(message, v, r, s);
    }

    /// construit un hachage préfixé pour imiter le comportement de eth_sign.
    function prefixed(bytes32 hash) internal pure returns (bytes32) {
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
    }
}

Écrire un canal de paiement simplifié

Alice construit maintenant une implémentation simple mais complète d’un paiement canaliser. Les canaux de paiement utilisent des signatures cryptographiques pour effectuer transferts répétés d’Ether en toute sécurité, instantanément et sans frais de transaction.

Qu’est-ce qu’un canal de paiement ?

Les canaux de paiement permettent aux participants d’effectuer des transferts répétés d’Ether sans utiliser de transactions. Cela signifie que vous pouvez éviter les retards et les frais liés aux transactions. Nous allons explorer un simple canal de paiement unidirectionnel entre deux parties (Alice et Bob). Cela implique trois étapes :

  1. Alice finance un contrat intelligent avec Ether. Cela « ouvre » le canal de paiement.

  2. Alice signe des messages qui précisent combien de cet Ether est dû au destinataire. Cette étape est répétée pour chaque paiement.

  3. Bob « ferme » le canal de paiement, retire sa part de l’Ether et renvoie le reste à l’expéditeur.

Note

Seules les étapes 1 et 3 nécessitent des transactions Ethereum, l’étape 2 signifie que l’expéditeur transmet un message signé cryptographiquement au destinataire via des méthodes off-chain (exemple: par e-mail). Cela signifie que seules deux transactions sont nécessaires pour prendre en charge n’importe quel nombre de transferts.

Bob est assuré de recevoir ses fonds car le Smart Contract garde l’Ether et honore un message signé valide. Le Smart Contract impose également un délai d’attente, donc Alice est garantie de récupérer éventuellement ses fonds même si le le destinataire refuse de fermer le canal. C’est l’initiateur du paiement qui décide combien de temps il gardera le canal ouvert. Pour une transaction de courte durée, comme payer un cybercafé pour chaque minute d’accès au réseau, le paiement sera maintenu ouvert pendant une durée limitée. En revanche, pour un paiement récurrent, comme le paiement d’un salaire à un employé, le canal de paiement peuvent rester ouverts pendant plusieurs mois ou années.

Ouverture du canal de paiement

Pour ouvrir le canal de paiement, Alice déploie le Smart Contract, attachant l’Ether à garder et en précisant le destinataire prévu et une durée maximale d’existence du canal. C’est la fonction SimplePaymentChannel dans le contrat, à la fin de cette section.

Effectuer des paiements

Alice effectue des paiements en envoyant des messages signés à Bob. Cette étape est effectuée entièrement en dehors du réseau Ethereum. Les messages sont signés cryptographiquement par l’expéditeur, puis transmis directement au destinataire.

Chaque message comprend les informations suivantes :

  • L’adresse du Smart Contract, utilisée pour empêcher les attaques de relecture de contrats croisés.

  • Le montant total d’Ether qui est dû au destinataire jusqu’à présent.

Un canal de paiement n’est fermé qu’une seule fois, à la fin d’une série de virements. Pour cette raison, seul un des messages envoyés est racheté. C’est pourquoi chaque message spécifie un montant total cumulé d’Ether dû, plutôt que le montant du micropaiement individuel. Le destinataire choisira naturellement de racheter le message le plus récent car c’est celui avec le total le plus élevé. Le nonce par message n’est plus nécessaire, car le Smart Contrat n’honore qu’un seul message. L’adresse du contrat intelligent est toujours utilisée pour empêcher qu’un message destiné à un canal de paiement ne soit utilisé pour un autre canal.

Voici le code JavaScript modifié pour signer cryptographiquement un message de la section précédente :

function constructPaymentMessage(contractAddress, amount) {
    return abi.soliditySHA3(
        ["address", "uint256"],
        [contractAddress, amount]
    );
}

function signMessage(message, callback) {
    web3.eth.personal.sign(
        "0x" + message.toString("hex"),
        web3.eth.defaultAccount,
        callback
    );
}

// contractAddress est utilisé pour empêcher les attaques de relecture de contrats croisés.
// Le montant, en wei, spécifie la quantité d'Ether à envoyer.

function signPayment(contractAddress, amount, callback) {
    var message = constructPaymentMessage(contractAddress, amount);
    signMessage(message, callback);
}

Fermeture du canal de paiement

Lorsque Bob est prêt à recevoir ses fonds, il est temps de fermez le canal de paiement en appelant une fonction close sur le Smart Contrat. La fermeture du canal paie au destinataire l’éther qui lui est dû et détruit le contrat, renvoyant tout Ether restant à Alice. À fermer le canal, Bob doit fournir un message signé par Alice.

Le Smart Contrat doit vérifier que le message contient une signature valide de l’expéditeur. Le processus pour effectuer cette vérification est le même que celui utilisé par le destinataire. Les fonctions isValidSignature et recoverSigner (Solidity) fonctionnent exactement comme leur les fonctions JavaScript dans la section précédente, cette dernière fonction étant empruntée au contrat ReceiverPays.

Seul le destinataire du canal de paiement peut appeler la fonction close, qui transmet naturellement le message de paiement le plus récent parce que ce message porte le total dû le plus élevé. Si l’expéditeur était autorisé à appeler cette fonction, ils pourraient fournir un message avec un montant inférieur et tromper le destinataire sur ce qui lui est dû.

La fonction vérifie que le message signé correspond aux paramètres donnés. Si tout se vérifie, le destinataire reçoit sa part de l’Ether, et l’expéditeur reçoit le reste via un selfdestruction. Vous pouvez voir la fonction close dans le contrat complet.

Expiration du canal

Bob peut fermer le canal de paiement à tout moment, mais s’il ne le fait pas, Alice a besoin d’un moyen de récupérer ses fonds bloqués. Un délai d”expiration a été défini au moment du déploiement du contrat. Une fois ce délai atteint, Alice peut appeler claimTimeout pour récupérer ses fonds. Vous pouvez voir la fonction claimTimeout dans le contrat complet.

Après l’appel de cette fonction, Bob ne peut plus recevoir d’Ether, il est donc important que Bob ferme le canal avant que l’expiration ne soit atteinte.

Le contrat complet

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract SimplePaymentChannel {
    address payable public sender;      // The account sending payments.
    address payable public recipient;   // The account receiving the payments.
    uint256 public expiration;  // Timeout in case the recipient never closes.

    constructor (address payable recipientAddress, uint256 duration)
        payable
    {
        sender = payable(msg.sender);
        recipient = recipientAddress;
        expiration = block.timestamp + duration;
    }

    /// le destinataire peut fermer le canal à tout moment en présentant un
    /// montant signé de l'expéditeur. le destinataire recevra ce montant,
    /// et le reste reviendra à l'expéditeur
    function close(uint256 amount, bytes memory signature) external {
        require(msg.sender == recipient);
        require(isValidSignature(amount, signature));

        recipient.transfer(amount);
        selfdestruct(sender);
    }

    /// l'expéditeur peut prolonger l'expiration à tout moment
    function extend(uint256 newExpiration) external {
        require(msg.sender == sender);
        require(newExpiration > expiration);

        expiration = newExpiration;
    }

    /// si le timeout est atteint sans que le destinataire ferme le canal,
    /// puis l'Ether est renvoyé à l'expéditeur.
    function claimTimeout() external {
        require(block.timestamp >= expiration);
        selfdestruct(sender);
    }

    function isValidSignature(uint256 amount, bytes memory signature)
        internal
        view
        returns (bool)
    {
        bytes32 message = prefixed(keccak256(abi.encodePacked(this, amount)));

        // vérifie que la signature provient de l'expéditeur du paiement
        return recoverSigner(message, signature) == sender;
    }

    /// Toutes les fonctions ci-dessous sont extraites du chapitre
    /// chapitre 'Création et vérification de signatures'.

    function splitSignature(bytes memory sig)
        internal
        pure
        returns (uint8 v, bytes32 r, bytes32 s)
    {
        require(sig.length == 65);

        assembly {
            // first 32 bytes, after the length prefix
            r := mload(add(sig, 32))
            // second 32 bytes
            s := mload(add(sig, 64))
            // final byte (first byte of the next 32 bytes)
            v := byte(0, mload(add(sig, 96)))
        }

        return (v, r, s);
    }

    function recoverSigner(bytes32 message, bytes memory sig)
        internal
        pure
        returns (address)
    {
        (uint8 v, bytes32 r, bytes32 s) = splitSignature(sig);

        return ecrecover(message, v, r, s);
    }

    /// construit un hachage préfixé pour imiter le comportement de eth_sign.
    function prefixed(bytes32 hash) internal pure returns (bytes32) {
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
    }
}

Note

La fonction splitSignature n’utilise pas toutes les sécurités nécessaires pour un Smart Contrat sécurisé. Une véritable implémentation devrait utiliser une bibliothèque plus rigoureusement testée, comme la version d’openzepplin de ce code.

Vérification des paiements

Contrairement à la section précédente, les messages d’un canal de paiement ne sont pas racheté tout de suite. Le destinataire garde une trace du dernier message et l’échange lorsqu’il est temps de fermer le canal de paiement. Cela signifie que c’est critique que le destinataire effectue sa propre vérification de chaque message. Sinon, il n’y a aucune garantie que le destinataire pourra être payé à la fin.

Le destinataire doit vérifier chaque message en utilisant le processus suivant :

  1. Vérifiez que l’adresse du contrat dans le message correspond au canal de paiement.

  2. Vérifiez que le nouveau total correspond au montant attendu.

  3. Vérifiez que le nouveau total ne dépasse pas le montant d’Ether bloqué.

  4. Vérifiez que la signature est valide et provient de l’expéditeur du canal de paiement.

Nous utiliserons la librairie ethereumjs-util pour écrire cette vérification. L’étape finale peut être effectuée de plusieurs façons, et nous utilisons JavaScript. Le code suivant emprunte la fonction constructPaymentMessage au code JavaScript de signature ci-dessus :

// cela imite le comportement de préfixation de la méthode eth_sign JSON-RPC.
function prefixed(hash) {
    return ethereumjs.ABI.soliditySHA3(
        ["string", "bytes32"],
        ["\x19Ethereum Signed Message:\n32", hash]
    );
}

function recoverSigner(message, signature) {
    var split = ethereumjs.Util.fromRpcSig(signature);
    var publicKey = ethereumjs.Util.ecrecover(message, split.v, split.r, split.s);
    var signer = ethereumjs.Util.pubToAddress(publicKey).toString("hex");
    return signer;
}

function isValidSignature(contractAddress, amount, signature, expectedSigner) {
    var message = prefixed(constructPaymentMessage(contractAddress, amount));
    var signer = recoverSigner(message, signature);
    return signer.toLowerCase() ==
        ethereumjs.Util.stripHexPrefix(expectedSigner).toLowerCase();
}

Contrats modulaires (Librairie)

Une approche modulaire de la construction de vos contrats vous aide à réduire la complexité et améliorer la lisibilité ce qui aidera à identifier les bugs et les vulnérabilités pendant le développement et la relecture de code. Si vous spécifiez et contrôlez le comportement de chaque module, les interactions que vous devrez prendre en compte sont uniquement celles entre les spécifications du module et non toutes les autres parties mobiles du contrat. Dans l’exemple ci-dessous, le contrat utilise la méthode move des Balances (library) pour vérifier que les soldes envoyés entre les adresses correspondent à ce que vous attendez. Ainsi, la library Balances fournit un composant isolé des contrats qui suit correctement les soldes des comptes. Il est facile de vérifier que la library Balances ne produise jamais de soldes négatifs ou de débordements grâce au terme require() De ce faites, la somme de tous les soldes est un invariant sur la durée de vie du contrat.

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;

library Balances {
    function move(mapping(address => uint256) storage balances, address from, address to, uint amount) internal {
        require(balances[from] >= amount);
        require(balances[to] + amount >= balances[to]);
        balances[from] -= amount;
        balances[to] += amount;
    }
}

contract Token {
    mapping(address => uint256) balances;
    using Balances for *;
    mapping(address => mapping (address => uint256)) allowed;

    event Transfer(address from, address to, uint amount);
    event Approval(address owner, address spender, uint amount);

    function transfer(address to, uint amount) external returns (bool success) {
        balances.move(msg.sender, to, amount);
        emit Transfer(msg.sender, to, amount);
        return true;

    }

    function transferFrom(address from, address to, uint amount) external returns (bool success) {
        require(allowed[from][msg.sender] >= amount);
        allowed[from][msg.sender] -= amount;
        balances.move(from, to, amount);
        emit Transfer(from, to, amount);
        return true;
    }

    function approve(address spender, uint tokens) external returns (bool success) {
        require(allowed[msg.sender][spender] == 0, "");
        allowed[msg.sender][spender] = tokens;
        emit Approval(msg.sender, spender, tokens);
        return true;
    }

    function balanceOf(address tokenOwner) external view returns (uint balance) {
        return balances[tokenOwner];
    }
}