ABI courtes pour l'optimisation des données d'appel
Introduction
Dans cet article, vous en apprendrez plus sur les rollups optimistes, le coût des transactions qui leur est appliqué et la manière dont cette structure de coûts différente nous oblige à optimiser pour des éléments différents de ceux du réseau principal d'Ethereum. Vous apprendrez également comment mettre en œuvre cette optimisation.
Transparence totale
Je suis un employé à temps plein d'Optimismopens in a new tab, les exemples de cet article seront donc exécutés sur Optimism. Cependant, la technique expliquée ici devrait aussi bien fonctionner pour d'autres rollups.
Terminologie
Lorsque l'on parle des rollups, le terme « couche 1 » (L1) est utilisé pour le réseau principal, le réseau de production Ethereum. Le terme « couche 2 » (L2) est utilisé pour le rollup ou tout autre système qui s'appuie sur L1 pour la sécurité, mais qui effectue la plupart de son traitement hors chaîne.
Comment pouvons-nous réduire davantage le coût des transactions L2 ?
Les rollups optimistes doivent conserver un enregistrement de chaque transaction historique afin que n'importe qui puisse les parcourir et vérifier que l'état actuel est correct. Le moyen le plus économique d'inscrire des données sur le réseau principal d'Ethereum est de les écrire en tant que données d'appel. Cette solution a été choisie à la fois par Optimismopens in a new tab et Arbitrumopens in a new tab.
Coût des transactions L2
Le coût des transactions L2 se compose de deux éléments :
- Le traitement L2, qui est généralement extrêmement bon marché
- Le stockage L1, qui est lié aux coûts de gaz du réseau principal
Au moment où j'écris ces lignes, sur Optimism, le coût du gaz L2 est de 0,001 Gwei. Le coût du gaz L1, en revanche, est d'environ 40 gwei. Vous pouvez voir les prix actuels iciopens in a new tab.
Un octet de données d'appel coûte soit 4 gaz (s'il est nul), soit 16 gaz (s'il s'agit de n'importe quelle autre valeur). L'une des opérations les plus coûteuses sur l'EVM est l'écriture sur le stockage. Le coût maximum de l'écriture d'un mot de 32 octets sur le stockage en L2 est de 22 100 gaz. Actuellement, cela représente 22,1 gwei. Ainsi, si nous pouvons économiser un seul octet nul de données d'appel, nous pourrons écrire environ 200 octets sur le stockage et être tout de même gagnants.
L'ABI
La grande majorité des transactions accèdent à un contrat provenant d'un compte externe. La plupart des contrats sont écrits en Solidity et interprètent leur champ de données selon l'interface binaire de l'application (ABI)opens in a new tab.
Cependant, l'ABI a été conçue pour L1, où un octet de données d'appel coûte approximativement la même chose que quatre opérations arithmétiques, et non pour L2 où un octet de données d'appel coûte plus de mille opérations arithmétiques. Les données d'appel sont divisées comme suit :
| Section | Longueur | Octets | Octets gaspillés | Gaz gaspillé | Octets nécessaires | Gaz nécessaire |
|---|---|---|---|---|---|---|
| Sélecteur de fonction | 4 | 0-3 | 3 | 48 | 1 | 16 |
| Zéros | 12 | 4-15 | 12 | 48 | 0 | 0 |
| Adresse de destination | 20 | 16-35 | 0 | 0 | 20 | 320 |
| Montant | 32 | 36-67 | 17 | 64 | 15 | 240 |
| Total | 68 | 160 | 576 |
Explication :
- Sélecteur de fonction : Le contrat a moins de 256 fonctions, nous pouvons donc les distinguer avec un seul octet. Ces octets sont généralement non nuls et coûtent donc seize gazopens in a new tab.
- Zéros : Ces octets sont toujours nuls car une adresse de vingt octets ne nécessite pas un mot de trente-deux octets pour la contenir.
Les octets qui contiennent la valeur zéro coûtent quatre gaz (voir le Livre Jauneopens in a new tab, Annexe G,
p. 27, la valeur pour
Gtxdatazero). - Montant : Si nous supposons que dans ce contrat
decimalsest de dix-huit (la valeur normale) et que le montant maximum de jetons que nous transférons sera de 1018, nous obtenons un montant maximum de 1036. 25615 > 1036, donc quinze octets suffisent.
Un gaspillage de 160 gaz sur L1 est normalement négligeable. Une transaction coûte au moins 21 000 gazopens in a new tab, donc 0,8 % supplémentaires n'ont pas d'importance.
Cependant, sur L2, les choses sont différentes. La quasi-totalité du coût de la transaction consiste à l'écrire sur L1.
En plus des données d'appel de la transaction, il y a 109 octets d'en-tête de transaction (adresse de destination, signature, etc.).
Le coût total est donc 109*16+576+160=2480, et nous en gaspillons environ 6,5 %.
Réduire les coûts lorsque vous ne contrôlez pas la destination
En supposant que vous n'ayez pas le contrôle sur le contrat de destination, vous pouvez toujours utiliser une solution similaire à celle-ciopens in a new tab. Passons en revue les fichiers pertinents.
Token.sol
Ceci est le contrat de destinationopens in a new tab.
Il s'agit d'un contrat standard ERC-20, avec une fonction supplémentaire.
Cette fonction faucet permet à n'importe quel utilisateur d'obtenir un jeton à utiliser.
Elle rendrait inutile la création d'un contrat ERC-20, mais elle facilite la vie quand un ERC-20 existe uniquement pour faciliter les tests.
1 /**2 * @dev Donne à l'appelant 1000 jetons avec lesquels jouer3 */4 function faucet() external {5 _mint(msg.sender, 1000);6 } // function faucetCalldataInterpreter.sol
Ceci est le contrat que les transactions sont censées appeler avec des données d'appel plus courtesopens in a new tab. Revenons dessus ligne par ligne.
1//SPDX-License-Identifier: Unlicense2pragma solidity ^0.8.0;345import { OrisUselessToken } from "./Token.sol";Nous avons besoin de la fonction du jeton pour savoir comment l'appeler.
1contract CalldataInterpreter {23 OrisUselessToken public immutable token;L'adresse du jeton pour lequel nous sommes un mandataire (proxy).
12 /**3 * @dev Spécifier l'adresse du jeton4 * @param tokenAddr_ Adresse du contrat ERC-205 */6 constructor(7 address tokenAddr_8 ) {9 token = OrisUselessToken(tokenAddr_);10 } // constructeurAfficher toutL'adresse du jeton est le seul paramètre que nous devons spécifier.
1 function calldataVal(uint startByte, uint length)2 private pure returns (uint) {Lire une valeur à partir des données d'appel.
1 uint _retVal;23 require(length < 0x21,4 "La limite de longueur de calldataVal est de 32 octets");56 require(length + startByte <= msg.data.length,7 "calldataVal essaie de lire au-delà de calldatasize");Nous allons charger un unique mot de 32 octets (256 bits) en mémoire et supprimer les octets qui ne font pas partie du champ que nous voulons. Cet algorithme ne fonctionne pas pour des valeurs de plus de 32 octets, et bien sûr nous ne pouvons pas lire au-delà de la fin des données d'appel. Sur L1, il peut être nécessaire d'ignorer ces tests pour économiser du gaz, mais sur L2, le gaz est extrêmement bon marché, ce qui permet toutes les vérifications de cohérence auxquelles nous pouvons penser.
1 assembly {2 _retVal := calldataload(startByte)3 }Nous aurions pu copier les données de l'appel à fallback() (voir ci-dessous), mais il est plus facile d'utiliser Yulopens in a new tab, le langage d'assemblage de l'EVM.
Ici, nous utilisons l'opcode CALLDATALOADopens in a new tab pour lire les octets startByte à startByte+31 dans la pile.
En général, la syntaxe d'un opcode dans Yul est <nom de l'opcode>(<première valeur de la pile, le cas échéant>,<deuxième valeur de la pile, le cas échéant>...).
12 _retVal = _retVal >> (256-length*8);Seuls les octets de longueur les plus significatifs font partie du champ, donc nous effectuons un décalage à droiteopens in a new tab pour nous débarrasser des autres valeurs.
Ceci présente l'avantage supplémentaire de déplacer la valeur à droite du champ, il s'agit donc de la valeur elle-même plutôt que la valeur multipliée par 256quelque chose.
12 return _retVal;3 }456 fallback() external {Lorsqu'un appel à un contrat Solidity ne correspond à aucune des signatures de fonction, il appelle la fonction fallback()opens in a new tab (en supposant qu'il y en ait une).
Dans le cas de CalldataInterpreter, n'importe quel appel arrive ici car il n'y a pas d'autres fonctions external ou public.
1 uint _func;23 _func = calldataVal(0, 1);Lit le premier octet des données d'appel, qui nous indique la fonction. Il y a deux raisons pour lesquelles une fonction ne serait pas disponible ici :
- Les fonctions
pureouviewne modifient pas l'état et ne coûtent pas de gaz (lorsqu'elles sont appelées hors chaîne). Essayer de réduire leur coût en gaz n'a aucun sens. - Fonctions qui s'appuient sur
msg.senderopens in a new tab. La valeur demsg.sendersera l'adresse deCalldataInterpreter, et non celle de l'appelant.
Malheureusement, en examinant les spécifications ERC-20opens in a new tab, cela ne laisse qu'une seule fonction, transfer.
Cela nous laisse avec uniquement deux fonctions : transfer (parce que nous pouvons appeler transferFrom) et faucet (parce que nous pouvons retourner les jetons à celui qui nous a appelés).
12 // Appeler les méthodes de changement d'état du jeton en utilisant3 // les informations des données d'appel45 // faucet6 if (_func == 1) {Un appel à faucet(), qui n'a pas de paramètres.
1 token.faucet();2 token.transfer(msg.sender,3 token.balanceOf(address(this)));4 }Après avoir appelé token.faucet(), nous obtenons des jetons. Cependant, en tant que contrat mandataire, nous n'avons pas besoin de jetons.
L'EOA (compte détenu en externe) ou le contrat qui nous a appelés en a besoin.
Nous transférons donc tous nos jetons à ceux qui nous ont appelés.
1 // transfer (supposons que nous ayons une allocation pour cela)2 if (_func == 2) {Le transfert de jetons nécessite deux paramètres : l'adresse de destination et le montant.
1 token.transferFrom(2 msg.sender,Nous autorisons uniquement les appelants à transférer les jetons qu'ils possèdent
1 address(uint160(calldataVal(1, 20))),L'adresse de destination commence à l'octet n° 1 (l'octet n° 0 est la fonction). En tant qu'adresse, elle fait 20 octets de long.
1 calldataVal(21, 2)Pour ce contrat particulier, nous supposons que le nombre maximum de jetons que n'importe qui voudra transférer tiendra dans deux octets (moins de 65 536).
1 );2 }Dans l'ensemble, un transfert prend 35 octets de données d'appel :
| Section | Longueur | Octets |
|---|---|---|
| Sélecteur de fonction | 1 | 0 |
| Adresse de destination | 32 | 1-32 |
| Montant | 2 | 33-34 |
1 } // fallback23} // contract CalldataInterpretertest.js
Ce test unitaire JavaScriptopens in a new tab nous montre comment utiliser ce mécanisme (et comment vérifier qu'il fonctionne correctement). Je vais supposer que vous comprenez chaiopens in a new tab et ethersopens in a new tab et que je n'expliquerai que les parties qui s'appliquent spécifiquement au contrat.
1const { expect } = require("chai");23describe("CalldataInterpreter", function () {4 it("Devrait nous permettre d'utiliser des jetons", async function () {5 const Token = await ethers.getContractFactory("OrisUselessToken")6 const token = await Token.deploy()7 await token.deployed()8 console.log("Adresse du jeton :", token.address)910 const Cdi = await ethers.getContractFactory("CalldataInterpreter")11 const cdi = await Cdi.deploy(token.address)12 await cdi.deployed()13 console.log("Adresse CalldataInterpreter :", cdi.address)1415 const signer = await ethers.getSigner()Afficher toutNous commençons par déployer les deux contrats.
1 // Obtenir des jetons pour jouer avec2 const faucetTx = {Nous ne pouvons pas utiliser les fonctions de haut niveau que nous utiliserions normalement (telles que token.faucet()) pour créer des transactions, car nous ne suivons pas l'ABI.
Au lieu de cela, nous devons construire nous-mêmes la transaction et ensuite l'envoyer.
1 to: cdi.address,2 data: "0x01"Nous devons fournir deux paramètres pour la transaction :
to, l'adresse de destination. Il s'agit du contrat d'interprétation des données d'appel.data, les données d'appel à envoyer. Dans le cas d'un appel au faucet, les données sont un octet unique,0x01.
12 }3 await (await signer.sendTransaction(faucetTx)).wait()Nous appelons la méthode sendTransaction du signataireopens in a new tab car nous avons déjà spécifié la destination (faucetTx.to) et nous avons besoin que la transaction soit signée.
1// Vérifier que le faucet fournit les jetons correctement2expect(await token.balanceOf(signer.address)).to.equal(1000)Ici, nous vérifions le solde.
Il n'est pas nécessaire d'économiser du gaz sur les fonctions de vue, nous les exécutons donc normalement.
1// Donner une allocation au CDI (les approbations ne peuvent pas être mandatées)2const approveTX = await token.approve(cdi.address, 10000)3await approveTX.wait()4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)Donner à l'interprète de données d'appel une allocation pour pouvoir effectuer des transferts.
1// Transférer des jetons2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"3const transferTx = {4 to: cdi.address,5 data: "0x02" + destAddr.slice(2, 42) + "0100",6}Créer une transaction de transfert. Le premier octet est « 0x02 », suivi de l'adresse de destination, et enfin du montant (0x0100, qui correspond à 256 en décimal).
1 await (await signer.sendTransaction(transferTx)).wait()23 // Vérifier que nous avons 256 jetons en moins4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)56 // Et que notre destination les a reçus7 expect (await token.balanceOf(destAddr)).to.equal(256)8 }) // it9}) // describeAfficher toutRéduire le coût lorsque vous contrôlez le contrat de destination
Si vous avez le contrôle sur le contrat de destination, vous pouvez créer des fonctions qui contournent la vérification de msg.sender dans la mesure où elles font confiance à l'interpréteur des données d'appel.
Vous pouvez voir un exemple de la façon dont cela fonctionne ici, dans la branche control-contractopens in a new tab.
Si le contrat ne répondait qu'à des transactions externes, nous pourrions nous contenter d'un seul contrat. Cependant, cela briserait la composabilité. Il est préférable d'avoir un contrat qui répond aux appels ERC-20 normaux, et un autre contrat qui répond aux transactions avec des données d'appel courtes.
Token.sol
Dans cet exemple, nous pouvons modifier Token.sol.
Cela nous permet d'avoir un certain nombre de fonctions que seul le mandataire peut appeler.
Voici les nouvelles parties :
1 // La seule adresse autorisée à spécifier l'adresse CalldataInterpreter2 address owner;34 // L'adresse CalldataInterpreter5 address proxy = address(0);Le contrat ERC-20 doit connaître l'identité du mandataire autorisé. Cependant, nous ne pouvons pas définir cette variable dans le constructeur, car nous n'en connaissons pas encore la valeur. Ce contrat est instancié en premier car le mandataire attend l'adresse du jeton dans son constructeur.
1 /**2 * @dev Appelle le constructeur ERC20.3 */4 constructor(5 ) ERC20("Oris useless token-2", "OUT-2") {6 owner = msg.sender;7 }L'adresse du créateur (appelé propriétaire) est stockée ici car c'est la seule adresse autorisée à définir le mandataire.
1 /**2 * @dev définit l'adresse pour le mandataire (le CalldataInterpreter).3 * Ne peut être appelé qu'une seule fois par le propriétaire4 */5 function setProxy(address _proxy) external {6 require(msg.sender == owner, "Ne peut être appelé que par le propriétaire");7 require(proxy == address(0), "Le mandataire est déjà défini");89 proxy = _proxy;10 } // function setProxyAfficher toutLe mandataire dispose d'un accès privilégié, car il peut contourner les contrôles de sécurité.
Pour être sûr de pouvoir faire confiance au mandataire, nous ne laissons que le propriétaire appeler cette fonction, et une seule fois.
Une fois que proxy a une valeur réelle (non nulle), cette valeur ne peut pas changer, donc même si le propriétaire décide de devenir malveillant, ou si la mnémonique pour celui-ci est révélée, nous sommes toujours en sécurité.
1 /**2 * @dev Certaines fonctions ne peuvent être appelées que par le mandataire.3 */4 modifier onlyProxy {Ceci est une fonction modificatriceopens in a new tab, elle modifie le fonctionnement des autres fonctions.
1 require(msg.sender == proxy);Tout d'abord, vérifiez que nous avons été appelés par le mandataire et personne d'autre.
Sinon, revert.
1 _;2 }Si c'est le cas, exécutez la fonction que nous modifions.
1 /* Fonctions qui permettent au mandataire de servir de mandataire pour les comptes */23 function transferProxy(address from, address to, uint256 amount)4 public virtual onlyProxy() returns (bool)5 {6 _transfer(from, to, amount);7 return true;8 }910 function approveProxy(address from, address spender, uint256 amount)11 public virtual onlyProxy() returns (bool)12 {13 _approve(from, spender, amount);14 return true;15 }1617 function transferFromProxy(18 address spender,19 address from,20 address to,21 uint256 amount22 ) public virtual onlyProxy() returns (bool)23 {24 _spendAllowance(from, spender, amount);25 _transfer(from, to, amount);26 return true;27 }Afficher toutIl s'agit de trois opérations qui nécessitent normalement que le message provienne directement de l'entité qui transfère les jetons ou qui approuve une allocation. Nous avons ici une version mandataire de ces opérations qui :
- est modifiée par
onlyProxy()afin que personne d'autre ne soit autorisé à les contrôler. - Récupère l'adresse qui serait normalement
msg.senderen tant que paramètre supplémentaire.
CalldataInterpreter.sol
L'interpréteur de données d'appel est presque identique à celui ci-dessus, à la différence que les fonctions mandatées reçoivent un paramètre msg.sender et qu'il n'est pas nécessaire d'effectuer d'allocation pour le transfert.
1 // transfert (pas besoin d'allocation)2 if (_func == 2) {3 token.transferProxy(4 msg.sender,5 address(uint160(calldataVal(1, 20))),6 calldataVal(21, 2)7 );8 }910 // approbation11 if (_func == 3) {12 token.approveProxy(13 msg.sender,14 address(uint160(calldataVal(1, 20))),15 calldataVal(21, 2)16 );17 }1819 // transferFrom20 if (_func == 4) {21 token.transferFromProxy(22 msg.sender,23 address(uint160(calldataVal( 1, 20))),24 address(uint160(calldataVal(21, 20))),25 calldataVal(41, 2)26 );27 }Afficher toutTest.js
Il existe quelques différences entre le code de test précédent et celui-ci.
1const Cdi = await ethers.getContractFactory("CalldataInterpreter")2const cdi = await Cdi.deploy(token.address)3await cdi.deployed()4await token.setProxy(cdi.address)Nous devons indiquer au contrat ERC-20 à quel mandataire faire confiance
1console.log("Adresse CalldataInterpreter :", cdi.address)23// Besoin de deux signataires pour vérifier les allocations4const signers = await ethers.getSigners()5const signer = signers[0]6const poorSigner = signers[1]Pour vérifier approve() et transferFrom(), nous avons besoin d'un second signataire.
Nous l'appelons poorSigner car il ne reçoit aucun de nos jetons (il doit bien sûr avoir de l'ETH).
1// Transférer des jetons2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"3const transferTx = {4 to: cdi.address,5 data: "0x02" + destAddr.slice(2, 42) + "0100",6}7await (await signer.sendTransaction(transferTx)).wait()Étant donné que le contrat ERC-20 fait confiance au mandataire (cdi), nous n'avons pas besoin d'une allocation pour relayer les transferts.
1// approbation et transferFrom2const approveTx = {3 to: cdi.address,4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",5}6await (await signer.sendTransaction(approveTx)).wait()78const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"910const transferFromTx = {11 to: cdi.address,12 data: "0x04" + signer.address.slice(2, 42) + destAddr2.slice(2, 42) + "00FF",13}14await (await poorSigner.sendTransaction(transferFromTx)).wait()1516// Vérifier que la combinaison approbation / transferFrom a été effectuée correctement17expect(await token.balanceOf(destAddr2)).to.equal(255)Afficher toutTester les deux nouvelles fonctions.
Notez que transferFromTx nécessite deux paramètres d'adresse : le donneur de l'allocation et le destinataire.
Conclusion
Optimismopens in a new tab et Arbitrumopens in a new tab cherchent tous deux des moyens de réduire la taille des données d'appel écrites sur L1 et donc le coût des transactions. Cependant, en tant que fournisseurs d'infrastructures à la recherche de solutions génériques, nos capacités sont limitées. En tant que développeur de dapps, vous avez des connaissances spécifiques à l'application, ce qui vous permet d'optimiser vos données d'appel bien mieux que nous ne pourrions le faire avec une solution générique. Nous espérons que cet article vous aidera à trouver la solution idéale pour vos besoins.
Voir ici pour plus de mon travailopens in a new tab.
Dernière mise à jour de la page : 22 août 2025