Passer au contenu principal

Minimiser les ABIs pour l'optimisation des données d'appel

Couche 2
Intermédiaire
Ori Pomerantz
1 avril 2022
16 minutes de lecture minute read

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 :

  1. Le traitement L2, qui est généralement extrêmement bon marché
  2. 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 :

SectionLongueurBytesOctets gaspillésGaz gaspilléOctets nécessairesGaz nécessaire
Sélecteur de fonction40-3348116
Zéros124-15124800
Adresse de destination2016-350020320
Montant3236-67176415240
Total68160576

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 Gtxdatazero).
  • 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 with
3 */
4 function faucet() external {
5 _mint(msg.sender, 1000);
6 } // function faucet
Copier

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: Unlicense
2pragma solidity ^0.8.0;
3
4
5import { OrisUselessToken } from "./Token.sol";
Copier

Nous avons besoin de savoir comment appeler la fonction token.

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;
Copier

L'adresse du jeton pour lequel nous sommes un proxy.

1
2 /**
3 * @dev Specify the token address
4 * @param tokenAddr_ ERC-20 contract address
5 */
6 constructor(
7 address tokenAddr_
8 ) {
9 token = OrisUselessToken(tokenAddr_);
10 } // constructor
Afficher tout
Copier

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;
2
3 require(length < 0x21,
4 "calldataVal length limit is 32 bytes");
5
6 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>...).

1
2 _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.

1
2 return _retVal;
3 }
4
5
6 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;
2
3 _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 :

  1. Les fonctions pure ou view 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.
  2. Les fonctions reposent sur msg.sender(opens in a new tab). La valeur de msg.sender va être l'adresse du CalldataInterpreter, 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).

1
2 // Call the state changing methods of token using
3 // information from the calldata
4
5 // faucet
6 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 :

SectionLongueurBytes
Sélecteur de fonction10
Adresse de destination321-32
Montant233-34
1 } // fallback
2
3} // contract CalldataInterpreter
Copier

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");
2
3describe("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)
9
10 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)
14
15 const signer = await ethers.getSigner()
Afficher tout
Copier

Nous commençons par déployer les deux contrats.

1 // Get tokens to play with
2 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 :

  1. to, l'adresse de destination. Il s'agit de l'interpréteur des données d'appel du contrat.
  2. data, les données d'appel à envoyer. Dans le cas d'un appel faucet, les données sont un octet unique, 0x01.
1
2 }
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 correctly
2expect(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 tokens
2const 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()
2
3 // Check that we have 256 tokens less
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // And that our destination got them
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe
Afficher tout

Exemple

Si vous souhiatez voir ces fichiers en action sans les exécuter vous-même, suivez ces liens :

  1. Déploiement de OrisUselessToken(opens in a new tab) sur l'adresse 0x950c753c0edbde44a74d3793db738a318e9c8ce8(opens in a new tab).
  2. Déploiement de CalldataInterpreter(opens in a new tab) sur l'adresse 0x16617fea670aefe3b9051096c0eb4aeb4b3a5f55(opens in a new tab).
  3. Appel de faucet()(opens in a new tab).
  4. Appel de OrisUselessToken.approve()(opens in a new tab). Cet appel doit aller directement au contrat de jeton car le traitement repose sur msg.sender.
  5. 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 address
2 address owner;
3
4 // The CalldataInterpreter address
5 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 owner
4 */
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");
8
9 proxy = _proxy;
10 } // function setProxy
Afficher tout
Copier

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 */
2
3 function transferProxy(address from, address to, uint256 amount)
4 public virtual onlyProxy() returns (bool)
5 {
6 _transfer(from, to, amount);
7 return true;
8 }
9
10 function approveProxy(address from, address spender, uint256 amount)
11 public virtual onlyProxy() returns (bool)
12 {
13 _approve(from, spender, amount);
14 return true;
15 }
16
17 function transferFromProxy(
18 address spender,
19 address from,
20 address to,
21 uint256 amount
22 ) public virtual onlyProxy() returns (bool)
23 {
24 _spendAllowance(from, spender, amount);
25 _transfer(from, to, amount);
26 return true;
27 }
Afficher tout
Copier

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 :

  1. Est modifiée par onlyProxy() afin que personne d'autre ne soit autorisé à les contrôler.
  2. 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 }
9
10 // approve
11 if (_func == 3) {
12 token.approveProxy(
13 msg.sender,
14 address(uint160(calldataVal(1, 20))),
15 calldataVal(21, 2)
16 );
17 }
18
19 // transferFrom
20 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 tout
Copier

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)
2
3// Need two signers to verify allowances
4const 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 tokens
2const 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 transferFrom
2const approveTx = {
3 to: cdi.address,
4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",
5}
6await (await signer.sendTransaction(approveTx)).wait()
7
8const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"
9
10const 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()
15
16// Check the approve / transferFrom combo was done correctly
17expect(await token.balanceOf(destAddr2)).to.equal(255)
Afficher tout
Copier

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 :

  1. Déploiement de OrisUselessToken-2(opens in a new tab) à l'adresse 0xb47c1f550d8af70b339970c673bbdb2594011696(opens in a new tab).
  2. Déploiement de CalldataInterpreter(opens in a new tab) à l'adresse 0x0dccfd03e3aaba2f8c4ea4008487fd0380815892(opens in a new tab).
  3. Appel de setProxy()(opens in a new tab).
  4. Appel de faucet()(opens in a new tab).
  5. Appel de transferProxy()(opens in a new tab).
  6. Appel de approveProxy()(opens in a new tab).
  7. Appel de transferProxy()(opens in a new tab). Notez que cet appel provient d'une adresse différente des autres, poorSigner au lieu du signer.

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: @MATsxm(opens in a new tab), 15 août 2023

Ce tutoriel vous a été utile ?