Minimiser les ABIs 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 comment la structure de coûts distincte nous oblige à optimiser différents éléments sur le réseau principal Ethereum. Vous apprendrez également à implémenter cette optimisation.
Devoir de transparence
Je suis un employé à temps plein chez Optimism(opens in a new tab), les exemples illustrant 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 généralement utilisé pour le réseau principal, le réseau Ethereum de production. Le terme 'Couche 2' (L2) est utilisé pour les rollups ou tout autre système qui se base sur L1 pour la sécurité, mais qui réalise son traitement hors chaîne.
Comment pouvons-nous encore réduire le coût des transactions L2 ?
Les Rollups optimistes doivent conserver un registre de chaque historique de transaction afin que toute personne qui le souhaite puisse le passer en revue et vérifier que l'état actuel est correct. La façon la plus économique de récupérer des données sur le réseau principal Ethereum est de les écrire en tant que données d'appel. Cette solution a été choisie à la fois par Optimism(opens in a new tab) et Arbitrum(opens in a new tab).
Coût des transactions L2
Le coût des transactions L2 est composé de deux éléments :
- Le traitement L2, qui est généralement extrêmement bon marché
- Le stockage L1, lié aux coûts de gaz du réseau principal
Au moment d'écrire cet article, le coût de gaz L2 sur Optimism est de 0,001 Gwei Le coût de gaz L1, en revanche, est d'environ 40 gwei. Vous pouvez voir les prix actuels ici(opens 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 d'une autre valeur). L'une des opérations les plus coûteuses de l'EVM est d'écrire sur le stockage. Le coût maximum d'écriture d'un mot de 32 octets pour un stockage sur L2 est de 22 100 gaz. Soit actuellement 22,1 gwei. Si nous parvenons à sauvegarder un seul octet zéro de données d'appel, nous pourrons écrire environ 200 octets de stockage et sortir gagnants de l'opération.
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 conformément à l'interface binaire d'application (ABI)(opens in a new tab).
Cependant, l'ABI a été conçu pour L1, où un octet de données d'appels coûte approximativement la même chose que quatre opérations arithmétiques, et non pas pour L2 où un octet de données d'appel coûte plus de mille opérations arithmétiques. Par exemple, voici une transaction de transfert ERC-20(opens in a new tab). Les données d'appel sont divisées ainsi :
Section | Longueur | Bytes | 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 caractériser avec un seul octet. Ces octets sont typiquement non nuls et coûtent donc seize gaz(opens in a new tab).
- Zéros : Ces octets sont toujours nuls car une adresse de vingt-quatre octets ne nécessite pas un mot de trente-deux octets pour la contenir. Les octets qui contiennent la valeur zéro ont un coût de quatre gaz (voir le Livre Jaune(opens in a new tab), Annexe G, p. 27, la valeur de
G
txdatazero
). - Montant : Si nous supposons que dans ce contrat
les décimales
sont de dix-huit (la valeur normale) et que le nombre maximum de jetons que nous transférons sera de 1018, nous obtenons un montant maximum de 1036. 25615 > 1036, donc 15 octets suffisent.
Le gaspillage de 160 gaz sur L1 est normalement négligeable. Une transaction coûte un minimum de 21 000 gaz(opens in a new tab), ainsi, un supplément de 0,8 % n'a pas grande 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 la 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 de contrôle sur le contrat de destination, vous pouvez toujours utiliser une solution similaire à celle-ci(opens in a new tab). Passons en revue les fichiers pertinents.
Token.sol
Ceci est le contrat de destination(opens 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 Gives the caller 1000 tokens to play with3 */4 function faucet() external {5 _mint(msg.sender, 1000);6 } // function faucetCopier
Vous pouvez voir un exemple de ce contrat en cours de déploiement ici(opens in a new tab).
CalldataInterpreter.sol
Ceci est le contrat que les transactions sont censées appeler au moyen de données d'appel plus courtes(opens in a new tab). Revenons dessus ligne par ligne.
1//SPDX-License-Identifier: Unlicense2pragma solidity ^0.8.0;345import { OrisUselessToken } from "./Token.sol";Copier
Nous avons besoin de savoir comment appeler la fonction token.
1contract CalldataInterpreter {23 OrisUselessToken public immutable token;Copier
L'adresse du jeton pour lequel nous sommes un proxy.
12 /**3 * @dev Specify the token address4 * @param tokenAddr_ ERC-20 contract address5 */6 constructor(7 address tokenAddr_8 ) {9 token = OrisUselessToken(tokenAddr_);10 } // constructorAfficher toutCopier
L'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) {Copier
Lire une valeur dans les données d'appel.
1 uint _retVal;23 require(length < 0x21,4 "calldataVal length limit is 32 bytes");56 require(length + startByte <= msg.data.length,7 "calldataVal trying to read beyond calldatasize");Copier
Nous allons charger en mémoire un unique mot de 32 octets (256 bits) et supprimer les octets qui ne font pas partie du champ souhaité. Cet algorithme ne fonctionne pas pour des valeurs de plus de 32 octets, et bien sûr nous ne pouvons lire au-delà de la fin des données d'appel. Sur L1, il serait pertinent de ne pas réaliser ces tests pour économiser du gaz, mais sur L2, le gaz est extrêmement bon marché, ce qui permet de réaliser toutes les vérifications possibles.
1 assembly {2 _retVal := calldataload(startByte)3 }Copier
Nous aurions pu copier les données de l'appel à fallback()
(voir ci-dessous), mais il est plus facile d'utiliser Yul(opens in a new tab), le langage d'assemblage de l'EVM.
Nous utilisons ici l'opcode CALLDATALOAD(opens 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 <opcode name>(<first stack value, if any>,<second stack value, if any>...)
.
12 _retVal = _retVal >> (256-length*8);Copier
Seuls les octets de longueur
les plus significatives font partie du champ, donc nous décalons vers la droite(opens 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 {Copier
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
, tous les appels arrivent ici car il n'y a pas d'autres fonctions external
ou public
.
1 uint _func;23 _func = calldataVal(0, 1);Copier
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
pure
ouview
ne changent 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. - Les fonctions reposent sur
msg.sender
(opens in a new tab). La valeur demsg.sender
va être l'adresse duCalldataInterpreter
, pas celle de l'appelant.
Malheureusement, au regard des spécifications ERC-20(opens 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 // Call the state changing methods of token using3 // information from the calldata45 // faucet6 if (_func == 1) {Copier
Un appel à la fonction faucet()
, qui n'a pas de paramètres.
1 token.faucet();2 token.transfer(msg.sender,3 token.balanceOf(address(this)));4 }Copier
Après avoir appelé token.faucet()
, nous obtenons des jetons. Cependant, comme pour le contrat proxy, nous n'avons pas besoin des jetons. L'EOA (compte détenu en externe) ou le contrat qui nous appelait en a besoin. Nous transférons donc tous nos jetons à ceux qui nous ont appelés.
1 // transfer (assume we have an allowance for it)2 if (_func == 2) {Copier
Le transfert de jetons nécessite deux paramètres : l'adresse de destination et le montant.
1 token.transferFrom(2 msg.sender,Copier
Nous autorisons uniquement les appelants à transférer les jetons qu'ils possèdent
1 address(uint160(calldataVal(1, 20))),Copier
L'adresse de destination commence à l'octet #1 (l'octet #0 est la fonction). En tant qu'adresse, elle fait 20 octets de long.
1 calldataVal(21, 2)Copier
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 65536).
1 );2 }Copier
Dans l'ensemble, un transfert prend 35 octets de données d'appel :
Section | Longueur | Bytes |
---|---|---|
Sélecteur de fonction | 1 | 0 |
Adresse de destination | 32 | 1-32 |
Montant | 2 | 33-34 |
1 } // fallback23} // contract CalldataInterpreterCopier
test.js
Ce test unitaire JavaScript(opens 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 chai(opens in a new tab) et ethers(opens in a new tab) et uniquement vous expliquer les parties applicables spécifiquement au contrat.
1const { expect } = require("chai");23describe("CalldataInterpreter", function () {4 it("Should let us use tokens", async function () {5 const Token = await ethers.getContractFactory("OrisUselessToken")6 const token = await Token.deploy()7 await token.deployed()8 console.log("Token addr:", token.address)910 const Cdi = await ethers.getContractFactory("CalldataInterpreter")11 const cdi = await Cdi.deploy(token.address)12 await cdi.deployed()13 console.log("CalldataInterpreter addr:", cdi.address)1415 const signer = await ethers.getSigner()Afficher toutCopier
Nous commençons par déployer les deux contrats.
1 // Get tokens to play with2 const faucetTx = {
Nous ne pouvons pas utiliser les fonctions de haut niveau que nous utiliserions normalement (comme 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 de l'interpréteur des données d'appel du contrat.data
, les données d'appel à envoyer. Dans le cas d'un appel faucet, les données sont un octet unique,0x01
.
12 }3 await (await signer.sendTransaction(faucetTx)).wait()
Nous appelons la méthode du signataire sendTransaction
(opens 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// Check the faucet provides the tokens correctly2expect(await token.balanceOf(signer.address)).to.equal(1000)
Ici, nous vérifions le solde. Il n'est pas nécessaire d'économiser du gaz pour les fonctions view
, nous les exécutons donc normalement.
1// Give the CDI an allowance (approvals cannot be proxied)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 des données d'appel une allocation pour pouvoir effectuer des transferts.
1// Transfer tokens2const 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 est de 256 décimal).
1 await (await signer.sendTransaction(transferTx)).wait()23 // Check that we have 256 tokens less4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)56 // And that our destination got them7 expect (await token.balanceOf(destAddr)).to.equal(256)8 }) // it9}) // describeAfficher tout
Exemple
Si vous souhiatez voir ces fichiers en action sans les exécuter vous-même, suivez ces liens :
- Déploiement de
OrisUselessToken
(opens in a new tab) sur l'adresse0x950c753c0edbde44a74d3793db738a318e9c8ce8
(opens in a new tab). - Déploiement de
CalldataInterpreter
(opens in a new tab) sur l'adresse0x16617fea670aefe3b9051096c0eb4aeb4b3a5f55
(opens in a new tab). - Appel de
faucet()
(opens in a new tab). - Appel de
OrisUselessToken.approve()
(opens in a new tab). Cet appel doit aller directement au contrat de jeton car le traitement repose surmsg.sender
. - Appel de
transfer()
(opens in a new tab).
Réduire les coûts 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 msg.sender
dans la mesure où elles font confiance à l'interpréteur des données d'appel. Vous pouvez voir un exemple de comment cela fonctionne ici, dans la branche control-contract
(opens in a new tab).
Si le contrat ne répondait qu'à des transactions externes, nous pourrions nous contenter d'un seul contrat. Cependant, cela casserait la composabilité. Il est préférable d'avoir un contrat capable de répondre aux appels traditionnels ERC-20 et un autre contrat destiné aux transactions avec de courts appels de données.
Token.sol
Dans cet exemple, nous pouvons modifier Token.sol
. Cela nous permet d'avoir un certain nombre de fonctions que seul le proxy peut appeler. Voici les nouveaux éléments :
1 // The only address allowed to specify the CalldataInterpreter address2 address owner;34 // The CalldataInterpreter address5 address proxy = address(0);Copier
Le contrat ERC-20 doit connaître l'identité du proxy 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 instauré en premier car le proxy attend l'adresse du jeton dans son constructeur.
1 /**2 * @dev Calls the ERC20 constructor.3 */4 constructor(5 ) ERC20("Oris useless token-2", "OUT-2") {6 owner = msg.sender;7 }Copier
L'adresse du créateur (appelé owner
) est stockée ici car c'est la seule adresse autorisée à définir le proxy.
1 /**2 * @dev set the address for the proxy (the CalldataInterpreter).3 * Can only be called once by the owner4 */5 function setProxy(address _proxy) external {6 require(msg.sender == owner, "Can only be called by owner");7 require(proxy == address(0), "Proxy is already set");89 proxy = _proxy;10 } // function setProxyAfficher toutCopier
Le proxy 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 proxy, nous ne laissons que le propriétaire
appeler cette fonction, et qu'une seule fois. Une fois que le proxy
dispose d'une valeur réelle (pas zéro), cette valeur ne peut pas changer, donc même si le propriétaire décide de jouer au voyou, ou si l'élément mnémonique est révélé, nous restons en sécurité.
1 /**2 * @dev Some functions may only be called by the proxy.3 */4 modifier onlyProxy {Copier
Ceci est une fonction modifier
(opens in a new tab), qui modifie la façon dont les autres fonctions marchent.
1 require(msg.sender == proxy);Copier
Tout d'abord, vérifier que nous avons été appelés par le proxy et personne d'autre. Dans le cas contraire, annuler
.
1 _;2 }Copier
Si c'est le cas, exécuter la fonction que nous modifions.
1 /* Functions that allow the proxy to actually proxy for accounts */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 toutCopier
Il s'agit de trois opérations pour lesquelles le message doit normalement provenir directement de l'entité qui transfère les jetons ou approuve une allocation. Nous avons ici une version proxy 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.sender
en 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 proxy reçoivent un paramètre msg.sender
et qu'il n'est pas nécessaire d'effectuer d'allocation pour le transfert
.
1 // transfer (no need for allowance)2 if (_func == 2) {3 token.transferProxy(4 msg.sender,5 address(uint160(calldataVal(1, 20))),6 calldataVal(21, 2)7 );8 }910 // approve11 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 toutCopier
Test.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)Copier
Nous devons indiquer au contrat ERC-20 à quel proxy faire confiance
1console.log("CalldataInterpreter addr:", cdi.address)23// Need two signers to verify allowances4const signers = await ethers.getSigners()5const signer = signers[0]6const poorSigner = signers[1]Copier
Pour vérifier approve()
et transferFrom()
, nous avons besoin d'un second signataire. Nous l'appelons poorSigner
car il ne récupère aucun de nos jetons (il a bien entendu besoin d'ETH).
1// Transfer tokens2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"3const transferTx = {4 to: cdi.address,5 data: "0x02" + destAddr.slice(2, 42) + "0100",6}7await (await signer.sendTransaction(transferTx)).wait()Copier
Dans la mesure où le contrat ERC-20 fait confiance au proxy (cdi
), nous n'avons pas besoin d'une allocation pour relayer les transferts.
1// approval and 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// Check the approve / transferFrom combo was done correctly17expect(await token.balanceOf(destAddr2)).to.equal(255)Afficher toutCopier
Tester les deux nouvelles fonctions. Notez que transferFromTx
nécessite deux paramètres d'adresse : le donneur de l'allocation et le destinataire.
Exemple
Si vous souhiatez voir ces fichiers en action sans les exécuter vous-même, suivez ces liens :
- Déploiement de
OrisUselessToken-2
(opens in a new tab) à l'adresse0xb47c1f550d8af70b339970c673bbdb2594011696
(opens in a new tab). - Déploiement de
CalldataInterpreter
(opens in a new tab) à l'adresse0x0dccfd03e3aaba2f8c4ea4008487fd0380815892
(opens in a new tab). - Appel de
setProxy()
(opens in a new tab). - Appel de
faucet()
(opens in a new tab). - Appel de
transferProxy()
(opens in a new tab). - Appel de
approveProxy()
(opens in a new tab). - Appel de
transferProxy()
(opens in a new tab). Notez que cet appel provient d'une adresse différente des autres,poorSigner
au lieu dusigner
.
Conclusion
Optimism(opens in a new tab) et Arbitrum(opens in a new tab) recherchent des moyens de réduire la taille des données d'appel écrites en L1 et donc le coût des transactions. Cependant, en tant que fournisseurs d'infrastructures pour des solutions génériques, nos capacités sont limitées. En tant que développeur dApp, vous avez des connaissances spécifiques concernant 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. J'espère que cet article vous aidera à trouver la solution idéale pour vos besoins.
Dernière modification: @Shiva-Sai-ssb(opens in a new tab), 30 juin 2024