Blog/Les chaines.md

ERC20

Télécharger

Rechercher

Ces dernières années, la mode s'est tournée vers les blockchaines et l'AI. Aujourd'hui nous parlerons un peu des chaines, de ce qu'on peut faire avec quand on est un technicien.

ERC20

Une chaine de type ETH contient une machine virtuelle qui permet l'exécution de programmes. Ces programmes sont aussi nommés contrats. Une transaction est soit un appel vide (qui sert à transférer de l'argent), soit un déploiement de contrat soit un appel sur un de ces contrat. Un contrat possède alors une nouvelle addresse ETH ce qui lui permet de stocker de l'argent lui aussi. Il existe plusieurs interfaces de contrats qui ont été normalisées pour définir des protocoles. C'est le cas d'ERC20, norme qui définit l'interface d'un shitcoin (pardon pour les mots, je ne vais pas être tendre). Un genre de sous monnaie crypto qui ne sert pratiquement jamais à rien mais que les gens adorent accumuler, acheter, spéculer dessus. La plupart de ces ERC20 sont de véritables arnaques reliées à rien, certains sont reliés à des projets solides mais malgré tout, ces monnaies restent quand même inutiles. On ne peut rien acheter avec, ni effectuer de transaction. Dans le meilleur des cas, elles servent à voter.

interface IERC20 {
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
}

En général, on trouve aussi des fonctions du genre decimals ou symbol pour décrire comment afficher le token en question (notez bien que token et shitcoin sont très très souvent des synonymes).

La magie de cet erc20 est la suivante : on stocke dans la blockchaine un registre de qui possède cette nouvelle monaie en quelle quantité, et on fournit une interface qui permet de savoir combien on en possède, d'en transférer, et de gérer les autorisations de prélèvements. Jamais la création d'une monnaie n'a été aussi simple. Et en conséquence, ces monnaies ne vallent généralement rien. Mais des gens en achètent, et nous allons maintenant voir comment, mais avant tout, notons une chose formidable : là nous avons l'interface de l'ERC20, mais son implémentation est libre. Ce qui fait que la fonction balanceOf qui permet de savoir combien quelqu'un possède, peut-être le résultat d'un calcul ! Rien n'empèche le développeur du contrat de mettre n'importe quoi dedans ! Vous pouvez avoir acheté 10 shitcoins, et le lendemain matin n'avoir plus que 9 comme résultat du balanceOf puisque c'est un programme qui donne le résultat.

// exemple de merde déployée par des vilains.
    function balanceOf(address account) public view override returns (uint256) {
        if (_isExcluded[account]) return _tOwned[account];
        return tokenFromReflection(_rOwned[account]);
    }
    function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
        require(rAmount <= _rTotal, "Amount must be less than total reflections");
        uint256 currentRate =  _getRate();
        return rAmount.div(currentRate);
    }

Ici, on peut noter déjà que les gens qui ont une addresse enregistrée dans _isExcluded ont leur véritable valeur en token de renvoyée. Les autres cependant, voient leur portefeuille bouger en fonction du sens du vent. La fonction _getRate() fluctuant, la quantité dans leur portefeuille aussi. Là, on a pas une véritable décentralisation de l'argent comme l'esprit crypto-anar le prétend. On a un programme qui tourne de façon décentralisée, mais qui détient l'argent des gens CHEZ LUI. le owner controle le rate, je vous épargne le code de la fonction transfert, ils sont taxés pour donner de l'argent au créateur du contrat et faire gonfler les prix. Sauf si vous êtes un ami du développeur enregistré dans _isExcludedFromFee.

modifier onlyOwner() {
  require(_owner == _msgSender(), "Ownable: caller is not the owner");
  _;
}

Le onlyOwner montre bien qu'on a pas mis un service décentralisé en place. Il s'agit vraiment de décentraliser l'infrastructure et non la propriété. Ces technologies soit disant "sans chef" permettent de programmer la propriété, et les humains étant ce qu'ils sont l'ont compris très vite. Des projets avec exclusivement des DAO pour avoir une autorité basée sur l'investissement, on est vite passé à des projets qui sont bien la propriété de leurs développeurs. Vous pensiez avoir supprimé le tiers de confiance ? Il n'en est rien, la différence c'est que là, le tiers de confiance est un inconu qui n'a pas déposé sa pièce d'identité à la chambre de commerce.

Je vous épargne le reste du code de l'exemple que j'ai pris. Les tokens qui ne renvoient pas une vraie balance et qui prennent des taxes lors des transfers sont pratiquement toujours des arnaques. N'en achetez pas. Lisez le code des contrats, c'est important.

Swaps

Nous avons donc sur une même chaine, beaucoup d'ERC20 et une monaie qui sert à payer les transactions. Même si l'immense majorité de ces ERC20 ne servent à rien, certains sont reliés à des projets et ont quand même une valeur spéculative. D'autres sont là pour permettre, sur une chaine, de spéculer sur les monnaies d'autres chaines, dans ce cas, un intermédiaire de confiance possède des BTC par exemple, et va créer un ERC20 Btc sur une autre chaine, il les laisse s'échanger, et permet le transfert en échange d'une taxe. Ces intermédiaires permettent donc d'avoir du BTC, ETH, ou autre sur la plupart des chaines, et même du dollar (BUSD, USDT, USDC, etc...). Il faut donc une méthode qui permet de les échanger, DANS la chaine. Par une simple transaction.

Pour ce faire, il existe plusieurs méthodes mais la plus traditionelle est celle des uniswaps (pancake sur la BSC par exemple). Le principe est simple : on a des contrats sur lesquels on peut déposer de l'argent sous forme de paire de liquidités, et ces liquidités seront utilisées pour échanger. Typiquement, Martin dépose 90 000$ et 1 BTC sur le contrat en échange d'un jeton de participation (qui est lui aussi un ERC20 de la valeur de sa contribution à la liquidité). Et ensuite, lorsque quelqu'un veut vendre du BTC pour 10 000$, le contrat fournit 0.098BTC en échange, laissant 0.2% de taxe pour rémunérer les gens qui ont effectué les dépots. Ce qui fait que si Martin retire son argent, il aura 100 0000$ et 0.902 BTC. Le prix aura varié automatiquement mais je vous épargne ce calcul. Tout le monde y gagne, les gens qui déposent leur argent touchent un intéret, et les gens qui échangent peuvent le faire de façon instantanée à bas prix.

On trouve donc trois types de contrats :

  • Des routeurs, ce sont eux à qui on lance les ordres d'échanges. C'est eux qui vont demander l'autorisation de dépenser votre argent.
  • Des paires de liquidités qui possèdent donc l'argent. Deux devises par contrat. Et en valeur égale (si le contrat a Qa quantité de A, et Qb quantité de B, alors le prix de A est Qb/Qa B.)
  • Des factories, il s'agit d'un registre des paires de liquidités, ce contrat permet de créer de nouvelles paires si il en manque, et de trouver les paires existantes.

Chose curieuse, là on a un ERC20 (la paire) qui a une véritable utilité, qui possède de l'argent, mais que personne n'utilise comme tel. On a aussi besoin d'un ERC20 spécial qui s'appelle le W (Weth, Wbnb, etc...) qui sert à enrober la monnaie de la chaine dans un ERC20. Ce contrat a deux méthodes spéciales : déposit et withdraw.

//...
    mapping (address => uint)                       public  balanceOf;
    mapping (address => mapping (address => uint))  public  allowance;
//...
    function deposit() public payable {
        balanceOf[msg.sender] += msg.value;
        Deposit(msg.sender, msg.value);
    }
    function withdraw(uint wad) public {
        require(balanceOf[msg.sender] >= wad);
        balanceOf[msg.sender] -= wad;
        msg.sender.transfer(wad);
        Withdrawal(msg.sender, wad);
    }
//...

Vous pouvez regarder le lien vers son implémentation dans la BSC, vous verrez que cette fois-ci, curieusement, aucune triche sur la balance, aucun onlyOwner. Ce contrat est parfaitement décentralisé. Et si ça n'avait pas été le cas, on aurait eu bien peu de confiance envers lui.

Les fonctions importantes d'une factory sont les suivantes

//...
    function getPair(address tokenA, address tokenB) external view returns (address pair);
    function allPairs(uint) external view returns (address pair);
    function allPairsLength() external view returns (uint);
    function createPair(address tokenA, address tokenB) external returns (address pair);
//...

On a donc de quoi trouver l'addresse du contrat qui gère le swap entre tokenA et tokenB, de quoi trouver toutes les paires, et de quoi en créer une nouvelle.

Et celles d'une paire (je vous conseille de regarder tout le code)

// ...
interface IPancakeCallee {
    function pancakeCall(address sender, uint amount0, uint amount1, bytes calldata data) external;
}
contract PancakePair is IPancakePair, PancakeERC20 {
// ...
    address public factory;
    address public token0;
    address public token1;

    function getReserves() public view returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast) {
        _reserve0 = reserve0;
        _reserve1 = reserve1;
        _blockTimestampLast = blockTimestampLast;
    }

// ...

    // this low-level function should be called from a contract which performs important safety checks
    function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
        require(amount0Out > 0 || amount1Out > 0, 'Pancake: INSUFFICIENT_OUTPUT_AMOUNT');
        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
        require(amount0Out < _reserve0 && amount1Out < _reserve1, 'Pancake: INSUFFICIENT_LIQUIDITY');

        uint balance0;
        uint balance1;
        { // scope for _token{0,1}, avoids stack too deep errors
        address _token0 = token0;
        address _token1 = token1;
        require(to != _token0 && to != _token1, 'Pancake: INVALID_TO');
        if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
        if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
        if (data.length > 0) IPancakeCallee(to).pancakeCall(msg.sender, amount0Out, amount1Out, data);
        balance0 = IERC20(_token0).balanceOf(address(this));
        balance1 = IERC20(_token1).balanceOf(address(this));
        }
        uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
        uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
        require(amount0In > 0 || amount1In > 0, 'Pancake: INSUFFICIENT_INPUT_AMOUNT');
        { // scope for reserve{0,1}Adjusted, avoids stack too deep errors
        uint balance0Adjusted = (balance0.mul(10000).sub(amount0In.mul(25)));
        uint balance1Adjusted = (balance1.mul(10000).sub(amount1In.mul(25)));
        require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(10000**2), 'Pancake: K');
        }

        _update(balance0, balance1, _reserve0, _reserve1);
        emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
    }
// ...
}
// ...

Ici, nous avons un code plein de subtilités, qui a fait le bonheur de plein de personnes plus ou moins gentilles. En haut, nous avons la signature d'un callback : un contrat qui sera exécuté en milieu de transaction (ici : if (data.length > 0) IPancakeCallee(to).pancakeCall(....). Regardons donc le coeur de cette paire : la fonction qui exécute l'échange de cryptos : swap. Elle prend donc en paramètres :

  • uint amount0Out, le montant qu'on souhaite recevoir depuis la crypto0
  • uint amount1Out, le montant qu'on souhaite recevoir depuis la crypto1
  • address to, le destinataire
  • bytes calldata data, des données de callback qui serviront justement pour ce fameux pancakeCall

Nous avons donc dans l'ordre : la récupération de la réserve, le transfert de l'argent voulu, le calcul de combien on a envoyé, la vérification qu'on a bien envoyé un truc, la vérification d'une équation qui pourrait lancer l'erreur "Pancake: K", puis enfin la mise à jours des balances. Mais on paye quand dans l'histoire ? Et bien la subtilité est ici : soit on a déjà payé (en effet, la paire conserve en mémoire combien elle possédait des tokens, donc on peut payer avant d'appeller swap) soit on paye APRES avoir été servit. C'est ce qu'on nomme un flash loan. Emprunt flash. Et en cas de non remboursement, aucun problème la transaction est annullée. Les règles pour le paiement, la taxe, le price impact se trouvent simplement dans le respect de l'équation qui garantit que le stock grossit (sinon, erreur : "Pancake: K")

Ce qui donne pour le routeur :


// ...

    // given an input amount of an asset and pair reserves, returns the maximum output amount of the other asset
    function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
        require(amountIn > 0, 'PancakeLibrary: INSUFFICIENT_INPUT_AMOUNT');
        require(reserveIn > 0 && reserveOut > 0, 'PancakeLibrary: INSUFFICIENT_LIQUIDITY');
        uint amountInWithFee = amountIn.mul(9975);
        uint numerator = amountInWithFee.mul(reserveOut);
        uint denominator = reserveIn.mul(10000).add(amountInWithFee);
        amountOut = numerator / denominator;
    }

// ...

     // performs chained getAmountOut calculations on any number of pairs
    function getAmountsOut(address factory, uint amountIn, address[] memory path) internal view returns (uint[] memory amounts) {
        require(path.length >= 2, 'PancakeLibrary: INVALID_PATH');
        amounts = new uint[](path.length);
        amounts[0] = amountIn;
        for (uint i; i < path.length - 1; i++) {
            (uint reserveIn, uint reserveOut) = getReserves(factory, path[i], path[i + 1]);
            amounts[i + 1] = getAmountOut(amounts[i], reserveIn, reserveOut);
        }
    }

Deux fonctions utilitaires qui permettent de savoir combien on obtiendra si on paye la somme voulue. La première fonction dans une paire simple, l'autre dans un chemin composé de plusieurs paires (path) pour faire un échange plus compliqué (exemple : eth vers BTC puis vers BUSD)

Ces fonctions se contentent de calculer la taxe de 0.25%, et renvoient : (réserveOut * amountIn * 0.9975) / (reserveIn + amountIn * 0.9975) ce qui permet d'anticiper la variation du prix. La seconde fonction appelle la première en boucle pour chaque paire du chemin.


contract PancakeRouter is IPancakeRouter02 {
// ...

    // **** SWAP ****
    // requires the initial amount to have already been sent to the first pair
    function _swap(uint[] memory amounts, address[] memory path, address _to) internal virtual {
        for (uint i; i < path.length - 1; i++) {
            (address input, address output) = (path[i], path[i + 1]);
            (address token0,) = PancakeLibrary.sortTokens(input, output);
            uint amountOut = amounts[i + 1];
            (uint amount0Out, uint amount1Out) = input == token0 ? (uint(0), amountOut) : (amountOut, uint(0));
            address to = i < path.length - 2 ? PancakeLibrary.pairFor(factory, output, path[i + 2]) : _to;
            IPancakePair(PancakeLibrary.pairFor(factory, input, output)).swap(
                amount0Out, amount1Out, to, new bytes(0)
            );
        }
    }

Ici, nous avons une fonction qui prend en paramètre les quantités d'argent à sortir des paires, le chemin d'échange, la destination finale, et qui se contente d'appeller la fonction swap sur chaque paire avec le bon montant et le bon destinataire. Chaque swap ayant pour destinataire le swap suivant, sauf pour le dernier swap qui vous donne bien à vous l'argent échangé. Subtilité : la première paire n'a jamais été payée. C'est une fonction interne qu'on ne peut pas appeller.

    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external virtual override ensure(deadline) returns (uint[] memory amounts) {
        amounts = PancakeLibrary.getAmountsOut(factory, amountIn, path);
        require(amounts[amounts.length - 1] >= amountOutMin, 'PancakeRouter: INSUFFICIENT_OUTPUT_AMOUNT');
        TransferHelper.safeTransferFrom(
            path[0], msg.sender, PancakeLibrary.pairFor(factory, path[0], path[1]), amounts[0]
        );
        _swap(amounts, path, to);
    }
// ...
}

Elle est donc lancée ici : cette fonction est simple : on calcule les quantités d'argent nécessaires pour chaque paire, on paye la première paire, et on appelle la fonction précédentte pour terminer l'échange. Notez qu'à cause des variations de prix, le swapExact??For?? permet de dépenser exactement la somme d'entrée, et de demander à récupérer au moins la valeur posée en second paramètre. Il y a des variantes de cette fonction quand on veut dépenser un ETH et pas un token (dans ce cas, le routeur se contente de le wraper avant), et des variantes ou on souhaite acheter exactement une quantité précise, et dépenser au maximum une autre quantité. D'autres versions de cette fonction existent pour les shitcoins qui ont des taxes à la vente.

Notez aussi que cette fonction prend en paramètre le path. C'est à vous de le définir. Il existe plein d'outils qui permettent d'optimiser ce path, voir parfois de faire des swaps plus compliqués pour minimiser le price impact en passant par de multiples paths.

Voilà le fonctionnement des uniswaps V2. C'est assez brillant, assez efficace, et extrêmement souple.

DeFi (finance décentralisée)

Placer son argent sur une paire permet de bénéficier des taxes lors des swaps. C'est une opération financière intéressante, elle peut-être risquée car si à un moment le bitcoin monte, dans la paire on aura moins de bitcoin que ce qu'on a déposé. C'est un risque nommé "impermanent loss". Mais coté revenus passifs, c'est un sujet qu'il faut quand même observer.

Il existe d'autres méthodes de DeFi, on peut placer son argent sur d'autres plateformes qui permettent aux gens de jouer avec du levier par exemple. Les solutions sont multiples, ici je voulais montrer la technique et le code des uniswaps V2, le reste pourrait faire l'objet d'un autre article.

Mais rien n'est magique et rien n'est sans risque. De nombreuses plateformes ont été hack ou ont fermé, engloutissant les fonds de leurs "clients" qui ne sont protégés par rien du tout.

Arbitrage ?

Sur une chaine de type ETH ou BSC, il existe de nombreux clônes d'uniswap. Et même si il n'en existait qu'un seul, il existe de nombreuses façons de convertir du BUSD en BTC : on peut faire BUSD -> BTC mais encore BUSD -> ETH -> BTC, etc... Le passage par un token intermédiaire c'est ce qu'on nomme un triangle d'arbitrage. Et il se trouve qu'on peut rapidement imaginer faire une boucle : BUSD -> ETH -> BTC -> BUSD. Et si les prix des différentes paires étaient assez incohérents pour que la transaction soit rentable malgré la taxe, alors on peut y gagner de l'argent. C'est une tâche qui à première vue parasite le système. Quelqu'un peut gagner de l'argent sans en risquer. Mais c'est là la subtilité : en vérité, il rend un service : il permet de rendre les prix des différentes paires cohérents entre eux à epsilon près. Et il risque les frais de transaciton. La beauté de la blockchaine fait aussi que cette tache peut être effectuée par n'importe qui en théorie. En pratique c'est moins vrai. Mais en théorie, on a même pas besoin d'argent pour arbitrer puisque comme l'indique la fonction _swap des paires, on paye seulement à la fin, une fois qu'on a reçu l'argent. Ce qui fait qu'on peut arbitrer pour 10000$ même si on a que quelques centimes en poche. On doit alors se poser les questions suivantes :

  • Comment trouver les chemins qui sont déséquilibrés, le plus vite possible.
  • Comment calculer les montants optimaux de façon à rééquilibrer les prix et en tirer un gain.

Pour réaliser ça, il faut une combinaison d'un programme qui observe les transactions, et trouve ces chemins, et d'un contrat qui réalise la transaction après avoir calculé les montants. Pour le contrat, le tout est de pouvoir faire le flashloand et résoudre l'équation :

uint balance0Adjusted = (balance0.mul(10000).sub(amount0In.mul(25))); uint balance1Adjusted = (balance1.mul(10000).sub(amount1In.mul(25))); require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(10000**2), 'Pancake: K'); }

de façon à minimiser l'amountIn sur la première paire, et maximiser l'amountOut sur la dernière. Si vous avez des paires homogènes (même équation, tous des uniswapsV2, et mêmes fees) les calculs se font bien. Si vous avez plus que ça, bon courage...

Conclusion

Malheureusement, on en est plus qu'aux uniswaps V2, il y a beaucoup de types de paires maintenant. Les V3 qui sont des sortes de NFT, les paires pour les stables, ça rend les choses vraiment très complexes à manipuler. De plus, sur un arbitrage, ce n'est pas comme lors de spéculation : une seule personne gagne. Un seul développeur va réussir à effectuer la transaction d'arbitrage et donc tout va dépendre de sa capacité à faire exécuter sa transaction juste après le swap qui aura créé un déséquilibre dans les prix. Gagner des arbitrages en testnet (le bac à sable pour vérifier que ça fonctionne) c'est facile, mais en conditions réelles, là c'est autre chose.

Considérez ces codes comme des curiosités. Au mieux, vous allez rembourser vos frais de transactions, mais ça a peu de chances de vous rendre riche.