Sur Twitter, Iron Finance a déclaré : « Chère communauté, veuillez retirer des liquidités de tous les pools. Nous partagerons un post-mortem dès que nous aurons une meilleure compréhension de ce bank run. La garantie USDC est disponible pour le rachat comme d’habitude ».
Le « bank run », ou panique bancaire, est un phénomène dans lequel un grand nombre de clients d’une banque craignent qu’elle ne devienne insolvable et en retirent leurs dépôts le plus vite possible. Dans la cryptosphère, on l’appelle communément un « rug pull » (expression qui renvoie à l’idée de retirer de manière brusque le tapis/support sur lequel repose une personne, par exemple. Appliquée dans le contexte de DeFi, elle renvoie au retrait subit d’un pool de liquidité).
Certains utilisateurs n’ont pas manqué de faire entendre leur déception comme Josh Cryp qui a déclaré : « Mes frais de scolarité ont disparu !!! J’avais 3 000 $ là-bas et il me reste 0,50 $. Que me reste-t-il à retirer ??!!! Ce n’est pas juste ! Celui qui a causé ça devrait être puni... Que vais-je faire maintenant ? »
Ou encore cet autre qui a noté des limites dans le logiciel : « Vous n’avez pas fixé de limite de retrait, c’est ce qui l’a fait baisser, le prix de Titan est directement proportionnel à la quantité d’Iron miné ou à la demande d’Iron. Tandis que les gens commençaient à vendre Titan, les réserves d’IRON ont commencé à augmenter et le prix de Titan a chuté ».
Que s’est-il passé ?
Un stablecoin qu’est-ce que c’est ?
Un stablecoin est un actif numérique qui réplique la valeur faciale d’une monnaie fiduciaire, souvent le dollar, comme le leader Tether (USDT) ou encore le DAI de MakerDAO. Point commun entre tous ces actifs : ils ont été créés pour protéger leurs porteurs des fluctuations spéculatives.
Les stablecoins ont été imaginés comme un outil pour se prémunir contre la forte volatilité du marché des cryptomonnaies. Après une hausse impressionnante de l’ensemble des valeurs en 2017 suivie d’une chute brutale en 2018, certains investisseurs ont en effet éprouvé le besoin d’intégrer à leur portefeuille des cryptomonnaies plus stables dans une logique d’hedging (couverture du risque). Par ailleurs, les stablecoins pourraient également être un levier contribuant à l’adoption des applications décentralisées (Dapps) s’appuyant sur la technologie blockchain.
Le cas du stablecoin IRON
TITAN appartient à Iron Finance, un projet qui a commencé à faire le lien avec la chaîne de Polygon le 18 mai dans le but de tirer parti de l’efficacité de Polygon et de ses faibles frais de transaction.
Le projet tentait de démarrer un stablecoin partiellement garanti connu sous le nom de IRON. Le stablecoin, à son tour, se compose du stablecoin de Circle et Coinbase, USDC, ainsi que TITAN, et était arrimé à 1 $. Les Stablecoins sont des cryptomonnaies dont la valeur est attachée à des actifs financiers tels que des matières premières ou des devises émises par le gouvernement dans le but de les maintenir stables.
Dans le cas d’IRON, qui reçoit sa garantie de TITAN, les utilisateurs peuvent miner de nouveaux jetons stables via un mécanisme sur le réseau d’Iron Finance en verrouillant 25 % en TITAN et 75 % en USDC.
En raison du fonctionnement de la «*tokenomics*» de ce projet DeFi particulier, lorsque de nouveaux jetons stables IRON sont minés, la demande de TITAN augmente, faisant grimper son prix. À l’inverse, lorsque le cours du TITAN chute fortement, comme ce fut le cas mercredi, l’arrimage devient instable.
« Le prix de TITAN est passé à 65 $, puis est revenu à 60 $. Cela a poussé les baleines [grands investisseurs] à commencer à vendre », a déclaré Fred Schebesta, fondateur de Finder.com.au et investisseur d’Iron Finance. « Cela a ensuite conduit à un grand désarrimage de [IRON] ».
Lorsque les gros investisseurs ont commencé à se décharger de leurs jetons TITAN, ils ont inondé le marché de jetons excédentaires, provoquant une panique bancaire.
Tout cela suggère que les stablecoins algorithmiques, qui visent à stabiliser le prix en adaptant automatiquement l’offre à la demande, comportent leurs propres risques.
La déclaration d’Iron Finance
Iron Finance a déclaré à propos de la situation :
« Nous n’avons jamais pensé que cela arriverait, mais c’est ce qui s’est passé. Nous venons de vivre la première panique bancaire crypto à grande échelle au monde.
« Vers 10 h UTC le 16 juin-2021, nous avons remarqué que certaines baleines ont commencé à retirer des liquidités de IRON/USDC, puis ont vendu TITAN à IRON puis IRON à USDC directement aux pools de liquidités au lieu de racheter IRON, ce qui a entraîné une baisse du prix du IRON. TITAN est passé de 65 $ à 30 $ en 2 heures, qui a ensuite récupéré en 1 heure à 52 $ et IRON a complètement récupéré son ancrage (peg).
« Le protocole et le code fonctionnaient comme d’habitude, et pendant que nous surveillions l’activité de la blockchain, nous pensions que c’était juste une autre correction avant la récupération. En fait, IRON a déjà été hors ancrage au moins une douzaine de fois dans toute son histoire, tandis que nos jetons d’actions (STEEL et TITAN) ont crashé beaucoup plus fort au cours des semaines précédentes. Les choses ne semblaient pas seulement éprouvées au combat, elles l’étaient. Les utilisateurs sont restés calmes et ont fait confiance au code, nous aussi.
« Tout en travaillant sur le code fork de FRAX, nous avons étudié de près tous les projets qui ont fork FRAX et ne peuvent pas conserver leur ancrage à une date ultérieure. Pour cela, nous avons déjà mis en place de nombreuses améliorations comme notre mécanisme de doubles ratios de garanties, qui s’est déjà avéré efficace pour éviter les problèmes de out-peg qui se sont produits plusieurs fois dans des situations réelles auparavant.
« Plus tard, vers 15 heures UTC, quelques gros détenteurs ont recommencé à vendre. Cette fois, après qu’ils aient commencé, de nombreux utilisateurs ont paniqué et ont commencé à racheter IRON et à vendre leur TITAN. En raison du fonctionnement de l’oracle TWAP (Time-weighted average price, une base pour les stratégies de trading) de 10 minutes, le prix au comptant du TITAN baisse encore plus par rapport au prix de rachat du TWAP. Cela a provoqué une boucle de rétroaction négative, car plus de TITAN ont été créés (à la suite de rachats de IRON) et le prix a continué à baisser. Une définition classique d’un événement irrationnel et paniqué également connu sous le nom de bank run. Au moment d’écrire ces lignes, l’offre TITAN est de 27*805 milliards.
« À certains moments, le prix du TITAN est devenu si bas, proche de 0 en fait, ce qui a amené le contrat de rachat à annuler les transactions de rachat. Nous avons déjà mis en file d’attente le correctif pour cela, afin que les gens puissent à nouveau échanger à 17h UTC.
« Ce que nous venons de vivre est la pire chose qui puisse arriver au protocole, une banque historique gérée dans l’espace cryptographique high-tech moderne. N’oubliez pas qu’Iron.finance est un stablecoin partiellement garanti, qui est similaire à la banque de réserve fractionnaire du monde moderne. Lorsque les gens paniquent et courent vers la banque pour retirer leur argent dans un court laps de temps, la banque peut s’effondrer et s’effondrera ».
Un bogue de programmation embarrassant
La situation aurait pu n’être qu’une simple fluctuation du marché et une opportunité d’arbitrage pour l’ensemble de cryptographie, sans l’effet de la variation drastique des prix sur le contrat intelligent d’IRON, le code exécuté sur la BinanceSmartChain qui régit les transactions.
En termes simples, le contrat intelligent a échoué alors que le prix approchait de zéro.
« Puisque le prix du TITAN est tombé à 0, le contrat ne permet pas de rachats », a expliqué Iron Finance, la firme à l’origine du stablecoin partiel IRON, dans un communiqué mercredi. « Nous devrons attendre 12 heures pour que le délai soit écoulé avant que les rachats par l’USDC ne soient à nouveau possibles ».
Dans un billet publié jeudi sur Medium, une personne non identifiée a affirmé que la raison en était que le code de contrat intelligent IRON pour racheter (l’échanger contre une autre devise) le stablecoin pas si stable contient une erreur de limite :
Code JavaScript : | Sélectionner tout |
require(_share_price > 0, Invalid share price);
Voici le code qu’il a analysé :
Code JavaScript : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | // SPDX-License-Identifier: MIT pragma solidity 0.8.4; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "./interfaces/IShare.sol"; import "./interfaces/IDollar.sol"; import "./interfaces/ITreasury.sol"; import "./interfaces/IOracle.sol"; import "./interfaces/IPool.sol"; contract Pool is Ownable, ReentrancyGuard, Initializable, IPool { using SafeERC20 for ERC20; /* ========== ADDRESSES ================ */ address public oracle; address public collateral; address public dollar; address public treasury; address public share; /* ========== STATE VARIABLES ========== */ mapping(address => uint256) public redeem_share_balances; mapping(address => uint256) public redeem_collateral_balances; uint256 public override unclaimed_pool_collateral; uint256 public unclaimed_pool_share; mapping(address => uint256) public last_redeemed; // Constants for various precisions uint256 private constant PRICE_PRECISION = 1e6; uint256 private constant COLLATERAL_RATIO_PRECISION = 1e6; uint256 private constant COLLATERAL_RATIO_MAX = 1e6; // Number of decimals needed to get to 18 uint256 private missing_decimals; // Number of blocks to wait before being able to collectRedemption() uint256 public redemption_delay = 1; // AccessControl state variables bool public mint_paused = false; bool public redeem_paused = false; /* ========== MODIFIERS ========== */ modifier onlyTreasury() { require(msg.sender == treasury, "!treasury"); _; } /* ========== CONSTRUCTOR ========== */ function initialize( address _dollar, address _share, address _collateral, address _treasury ) external initializer onlyOwner { dollar = _dollar; share = _share; collateral = _collateral; treasury = _treasury; missing_decimals = 18 - ERC20(_collateral).decimals(); } /* ========== VIEWS ========== */ function info() external view returns ( uint256, uint256, uint256, bool, bool ) { return ( unclaimed_pool_collateral, // unclaimed amount of COLLATERAL unclaimed_pool_share, // unclaimed amount of SHARE getCollateralPrice(), // collateral price mint_paused, redeem_paused ); } function collateralReserve() public view returns (address) { return ITreasury(treasury).collateralReserve(); } function getCollateralPrice() public view override returns (uint256) { return IOracle(oracle).consult(); } /* ========== PUBLIC FUNCTIONS ========== */ function mint( uint256 _collateral_amount, uint256 _share_amount, uint256 _dollar_out_min ) external { require(mint_paused == false, "Minting is paused"); (, uint256 _share_price, , uint256 _tcr, , , uint256 _minting_fee, ) = ITreasury(treasury).info(); require(_share_price > 0, "Invalid share price"); uint256 _price_collateral = getCollateralPrice(); uint256 _total_dollar_value = 0; uint256 _required_share_amount = 0; if (_tcr > 0) { uint256 _collateral_value = ((_collateral_amount * (10**missing_decimals)) * _price_collateral) / PRICE_PRECISION; _total_dollar_value = (_collateral_value * COLLATERAL_RATIO_PRECISION) / _tcr; if (_tcr < COLLATERAL_RATIO_MAX) { _required_share_amount = ((_total_dollar_value - _collateral_value) * PRICE_PRECISION) / _share_price; } } else { _total_dollar_value = (_share_amount * _share_price) / PRICE_PRECISION; _required_share_amount = _share_amount; } uint256 _actual_dollar_amount = _total_dollar_value - ((_total_dollar_value * _minting_fee) / PRICE_PRECISION); require(_dollar_out_min <= _actual_dollar_amount, "slippage"); if (_required_share_amount > 0) { require(_required_share_amount <= _share_amount, "Not enough SHARE input"); IShare(share).poolBurnFrom(msg.sender, _required_share_amount); } if (_collateral_amount > 0) { _transferCollateralToReserve(msg.sender, _collateral_amount); } IDollar(dollar).poolMint(msg.sender, _actual_dollar_amount); } function redeem( uint256 _dollar_amount, uint256 _share_out_min, uint256 _collateral_out_min ) external { require(redeem_paused == false, "Redeeming is paused"); (, uint256 _share_price, , , uint256 _ecr, , , uint256 _redemption_fee) = ITreasury(treasury).info(); uint256 _collateral_price = getCollateralPrice(); require(_collateral_price > 0, "Invalid collateral price"); require(_share_price > 0, "Invalid share price"); uint256 _dollar_amount_post_fee = _dollar_amount - ((_dollar_amount * _redemption_fee) / PRICE_PRECISION); uint256 _collateral_output_amount = 0; uint256 _share_output_amount = 0; if (_ecr < COLLATERAL_RATIO_MAX) { uint256 _share_output_value = _dollar_amount_post_fee - ((_dollar_amount_post_fee * _ecr) / PRICE_PRECISION); _share_output_amount = (_share_output_value * PRICE_PRECISION) / _share_price; } if (_ecr > 0) { uint256 _collateral_output_value = ((_dollar_amount_post_fee * _ecr) / PRICE_PRECISION) / (10**missing_decimals); _collateral_output_amount = (_collateral_output_value * PRICE_PRECISION) / _collateral_price; } // Check if collateral balance meets and meet output expectation uint256 _totalCollateralBalance = ITreasury(treasury).globalCollateralBalance(); require(_collateral_output_amount <= _totalCollateralBalance, "<collateralBalance"); require(_collateral_out_min <= _collateral_output_amount && _share_out_min <= _share_output_amount, ">slippage"); if (_collateral_output_amount > 0) { redeem_collateral_balances[msg.sender] = redeem_collateral_balances[msg.sender] + _collateral_output_amount; unclaimed_pool_collateral = unclaimed_pool_collateral + _collateral_output_amount; } if (_share_output_amount > 0) { redeem_share_balances[msg.sender] = redeem_share_balances[msg.sender] + _share_output_amount; unclaimed_pool_share = unclaimed_pool_share + _share_output_amount; } last_redeemed[msg.sender] = block.number; // Move all external functions to the end IDollar(dollar).poolBurnFrom(msg.sender, _dollar_amount); if (_share_output_amount > 0) { _mintShareToCollateralReserve(_share_output_amount); } } function collectRedemption() external { require((last_redeemed[msg.sender] + redemption_delay) <= block.number, "<redemption_delay"); bool _send_share = false; bool _send_collateral = false; uint256 _share_amount; uint256 _collateral_amount; // Use Checks-Effects-Interactions pattern if (redeem_share_balances[msg.sender] > 0) { _share_amount = redeem_share_balances[msg.sender]; redeem_share_balances[msg.sender] = 0; unclaimed_pool_share = unclaimed_pool_share - _share_amount; _send_share = true; } if (redeem_collateral_balances[msg.sender] > 0) { _collateral_amount = redeem_collateral_balances[msg.sender]; redeem_collateral_balances[msg.sender] = 0; unclaimed_pool_collateral = unclaimed_pool_collateral - _collateral_amount; _send_collateral = true; } if (_send_share) { _requestTransferShare(msg.sender, _share_amount); } if (_send_collateral) { _requestTransferCollateral(msg.sender, _collateral_amount); } } /* ========== INTERNAL FUNCTIONS ========== */ function _transferCollateralToReserve(address _sender, uint256 _amount) internal { address _reserve = collateralReserve(); require(_reserve != address(0), "Invalid reserve address"); ERC20(collateral).safeTransferFrom(_sender, _reserve, _amount); } function _mintShareToCollateralReserve(uint256 _amount) internal { address _reserve = collateralReserve(); require(_reserve != address(0), "Invalid reserve address"); IShare(share).poolMint(_reserve, _amount); } function _requestTransferCollateral(address _receiver, uint256 _amount) internal { ITreasury(treasury).requestTransfer(collateral, _receiver, _amount); } function _requestTransferShare(address _receiver, uint256 _amount) internal { ITreasury(treasury).requestTransfer(share, _receiver, _amount); } /* ========== RESTRICTED FUNCTIONS ========== */ function toggleMinting() external onlyOwner { mint_paused = !mint_paused; } function toggleRedeeming() external onlyOwner { redeem_paused = !redeem_paused; } function setOracle(address _oracle) external onlyOwner { require(_oracle != address(0), "Invalid address"); oracle = _oracle; } function setRedemptionDelay(uint256 _redemption_delay) external onlyOwner { redemption_delay = _redemption_delay; } function setTreasury(address _treasury) external onlyOwner { require(_treasury != address(0), "Invalid address"); treasury = _treasury; emit TreasuryChanged(_treasury); } // EVENTS event TreasuryChanged(address indexed newTreasury); } |
Si c’est correct, l’utilisation de l’opérateur ">" (supérieur à) plutôt que ">=" (supérieur ou égal à) signifierait qu’une valeur TITAN évaluée comme zéro (qui dépend de la précision numérique du calcul) serait être considéré comme invalide et serait refusé pour le rachat.
Quoi qu’il en soit, cette affirmation n’a pas été confirmée par Iron Finance, et ce dernier s’est refusé à tout commentaire concernant cette éventualité. Cependant, la propre description de l’incident par Iron Finance décrit un scénario cohérent avec ce qui a été proposé par l’auteur non identifié de Medium :
« À certains moments, le prix du TITAN est devenu si bas, proche de 0 en fait, ce qui a amené le contrat de rachat à annuler les transactions de rachat », a déclaré Iron Finance dans son rapport d’incident jeudi (vers 16 heures UTC). « Nous avons déjà mis en file d’attente le correctif pour cela, afin que les gens puissent à nouveau échanger à 17h UTC ».
Sources : communiqué Iron Finance, tweets Iron Finance (1, 2), en savoir plus sur IRON, GitHub (code contrats), individu non identifié