Passer au contenu principal

ABI courtes pour l'optimisation des calldata

couche 2 (l2)
Intermédiaire
Ori Pomerantz
1 avril 2022
15 minutes de lecture

Introduction

Dans cet article, vous en apprendrez davantage sur les rollups optimistes, le coût des transactions sur ceux-ci, et comment cette structure de coûts différente nous oblige à optimiser d'autres éléments que sur le réseau principal Ethereum. Vous apprendrez également comment implémenter cette optimisation.

Divulgation complète

Je suis un employé à temps plein d'Optimism (opens in a new tab), les exemples de cet article s'exécuteront donc sur Optimism. Cependant, la technique expliquée ici devrait fonctionner tout aussi bien pour d'autres rollups.

Terminologie

Lorsqu'on parle de rollups, le terme « couche 1 (l1) » est utilisé pour le Réseau principal, le réseau Ethereum de production. Le terme « couche 2 (l2) » est utilisé pour le rollup ou tout autre système qui s'appuie sur la l1 pour la sécurité mais effectue la majeure partie de son traitement hors chaîne.

Comment pouvons-nous réduire davantage le coût des transactions sur la l2 ?

Les rollups optimistes doivent conserver un enregistrement de chaque transaction historique afin que quiconque puisse les parcourir et vérifier que l'état actuel est correct. Le moyen le moins cher d'intégrer des données dans le réseau principal Ethereum est de les écrire sous forme de calldata. 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 sur la l2

Le coût des transactions sur la l2 se compose de deux éléments :

  1. Le traitement sur la l2, qui est généralement extrêmement bon marché
  2. Le stockage sur la l1, qui est lié aux coûts en gaz du Réseau principal

Au moment où j'écris ces lignes, sur Optimism, le coût du gaz sur la l2 est de 0,001 gwei. Le coût du gaz sur la l1, en revanche, est d'environ 40 gwei. Vous pouvez consulter les prix actuels ici (opens in a new tab).

Un octet de calldata coûte soit 4 gaz (s'il est nul), soit 16 gaz (s'il a une autre valeur). L'une des opérations les plus coûteuses sur l'EVM est l'écriture dans le stockage. Le coût maximum de l'écriture d'un mot de 32 octets dans le stockage sur la l2 est de 22 100 gaz. Actuellement, cela représente 22,1 gwei. Donc, si nous pouvons économiser un seul octet nul de calldata, nous pourrons écrire environ 200 octets dans le stockage tout en restant gagnants.

L'ABI

La grande majorité des transactions accèdent à un contrat depuis un compte détenu par un tiers (EOA). 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 la l1, où un octet de calldata coûte environ la même chose que quatre opérations arithmétiques, et non pour la l2 où un octet de calldata coûte plus de mille opérations arithmétiques. Les calldata sont divisées ainsi :

SectionLongueurOctetsOctets 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 distinguer avec un seul octet. Ces octets sont généralement non nuls et par conséquent coûtent seize gaz (opens 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 contenant un zéro coûtent quatre gaz (voir le livre jaune (opens in a new tab), Annexe G, p. 27, la valeur pour Gtxdatazero).
  • Montant : Si nous supposons que dans ce contrat decimals est 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 la l1 est normalement négligeable. Une transaction coûte au moins 21 000 gaz (opens in a new tab), donc 0,8 % supplémentaire n'a pas d'importance. Cependant, sur la l2, les choses sont différentes. Presque tout le coût de la transaction réside dans son écriture sur la l1. En plus des calldata 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 de 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-ci (opens in a new tab). Passons en revue les fichiers pertinents.

Token.sol

Il s'agit du contrat de destination (opens in a new tab). C'est un contrat ERC-20 standard, avec une fonctionnalité supplémentaire. Cette fonction faucet permet à tout utilisateur d'obtenir des jetons à utiliser. Cela rendrait un contrat ERC-20 de production inutile, mais cela facilite la vie lorsqu'un ERC-20 n'existe que pour faciliter les tests.

    /**
     * @dev Donne à l'appelant 1000 jetons pour jouer
     */
    function faucet() external {
        _mint(msg.sender, 1000);
    }   // function faucet

CalldataInterpreter.sol

C'est le contrat que les transactions sont censées appeler avec des calldata plus courtes (opens in a new tab). Passons-le en revue ligne par ligne.

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;


import { OrisUselessToken } from "./Token.sol";

Nous avons besoin de la fonction du jeton pour savoir comment l'appeler.

contract CalldataInterpreter {
    OrisUselessToken public immutable token;

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

L'adresse du jeton est le seul paramètre que nous devons spécifier.

    function calldataVal(uint startByte, uint length)
        private pure returns (uint) {

Lire une valeur à partir des calldata.

        uint _retVal;

        require(length < 0x21,
            "calldataVal length limit is 32 bytes");

        require(length + startByte <= msg.data.length,
            "calldataVal trying to read beyond calldatasize");

Nous allons charger un seul 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 les valeurs de plus de 32 octets, et bien sûr, nous ne pouvons pas lire au-delà de la fin des calldata. Sur la l1, il pourrait être nécessaire d'ignorer ces tests pour économiser du gaz, mais sur la l2, le gaz est extrêmement bon marché, ce qui permet d'effectuer tous les contrôles de cohérence imaginables.

        assembly {
            _retVal := calldataload(startByte)
        }

Nous aurions pu copier les données de l'appel vers fallback() (voir ci-dessous), mais il est plus facile d'utiliser Yul (opens in a new tab), le langage d'assemblage de l'EVM.

Ici, nous utilisons le code d'opération CALLDATALOAD (opens in a new tab) pour lire les octets startByte à startByte+31 dans la pile. En général, la syntaxe d'un code d'opération dans Yul est <opcode name>(<first stack value, if any>,<second stack value, if any>...).


        _retVal = _retVal >> (256-length*8);

Seuls les length octets les plus significatifs font partie du champ, nous effectuons donc un décalage vers la droite (opens in a new tab) pour nous débarrasser des autres valeurs. Cela a l'avantage supplémentaire de déplacer la valeur vers la droite du champ, de sorte qu'il s'agit de la valeur elle-même plutôt que de la valeur multipliée par 256quelque chose.


        return _retVal;
    }


    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, tout appel arrive ici car il n'y a pas d'autres fonctions external ou public.

        uint _func;

        _func = calldataVal(0, 1);

Lire le premier octet des calldata, qui nous indique la fonction. Il y a deux raisons pour lesquelles une fonction ne serait pas disponible ici :

  1. Les fonctions qui sont pure ou view ne modifient pas l'état et ne coûtent pas de gaz (lorsqu'elles sont appelées hors chaîne). Il n'est pas logique d'essayer de réduire leur coût en gaz.
  2. Les fonctions qui s'appuient sur msg.sender (opens in a new tab). La valeur de msg.sender sera l'adresse de CalldataInterpreter, et non celle de l'appelant.

Malheureusement, en regardant les spécifications ERC-20 (opens in a new tab), cela ne laisse qu'une seule fonction, transfer. Cela ne nous laisse que deux fonctions : transfer (car nous pouvons appeler transferFrom) et faucet (car nous pouvons transférer les jetons en retour à celui qui nous a appelés).


        // Appelle les méthodes de changement d'état du jeton en utilisant
        // les informations des données d'appel

        // faucet
        if (_func == 1) {

Un appel à faucet(), qui n'a pas de paramètres.

            token.faucet();
            token.transfer(msg.sender,
                token.balanceOf(address(this)));
        }

Après avoir appelé token.faucet(), nous obtenons des jetons. Cependant, en tant que contrat proxy, nous n'avons pas besoin de jetons. Le compte détenu par un tiers (EOA) ou le contrat qui nous a appelés en a besoin. Nous transférons donc tous nos jetons à celui qui nous a appelés.

        // transfert (en supposant que nous avons une allocation pour cela)
        if (_func == 2) {

Le transfert de jetons nécessite deux paramètres : l'adresse de destination et le montant.

            token.transferFrom(
                msg.sender,

Nous n'autorisons les appelants à transférer que les jetons qu'ils possèdent

                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.

                calldataVal(21, 2)

Pour ce contrat particulier, nous supposons que le nombre maximum de jetons que quiconque voudrait transférer tient sur deux octets (moins de 65536).

            );
        }

Dans l'ensemble, un transfert prend 35 octets de calldata :

SectionLongueurOctets
Sélecteur de fonction10
Adresse de destination321-32
Montant233-34
    }   // fallback

}       // contract CalldataInterpreter

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 n'expliquer que les parties qui s'appliquent spécifiquement au contrat.

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

    // Obtenir des jetons pour jouer
    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 la transaction nous-mêmes, puis l'envoyer.

      to: cdi.address,
      data: "0x01"

Il y a deux paramètres que nous devons fournir pour la transaction :

  1. to, l'adresse de destination. Il s'agit du contrat interpréteur de calldata.
  2. data, les calldata à envoyer. Dans le cas d'un appel au faucet, la donnée est un seul octet, 0x01.

    }
    await (await signer.sendTransaction(faucetTx)).wait()

Nous appelons la méthode sendTransaction du signataire (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.

// Vérifier que le faucet fournit les jetons correctement
expect(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 view, nous les exécutons donc normalement.

// Donner au CDI une allocation (les approbations ne peuvent pas être mandatées)
const approveTX = await token.approve(cdi.address, 10000)
await approveTX.wait()
expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

Donner à l'interpréteur de calldata une allocation pour pouvoir effectuer des transferts.

// Transfert de jetons
const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
const transferTx = {
  to: cdi.address,
  data: "0x02" + destAddr.slice(2, 42) + "0100",
}

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).

Ré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 les vérifications de msg.sender car elles font confiance à l'interpréteur de calldata. Vous pouvez voir un exemple de la façon dont cela fonctionne ici, dans la branche control-contract (opens in a new tab).

Si le contrat ne répondait qu'aux transactions externes, nous pourrions nous contenter d'un seul contrat. Cependant, cela briserait la composabilité. Il est bien 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 proxy peut appeler. Voici les nouvelles parties :

    // La seule adresse autorisée à spécifier l'adresse du CalldataInterpreter
    address owner;

    // L'adresse du CalldataInterpreter
    address proxy = address(0);

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 instancié en premier car le proxy attend l'adresse du jeton dans son constructeur.

    /**
     * @dev Appelle le constructeur ERC-20.
     */
    constructor(
    ) ERC20("Oris useless token-2", "OUT-2") {
        owner = msg.sender;
    }

L'adresse du créateur (appelée owner) est stockée ici car c'est la seule adresse autorisée à définir le proxy.

Le proxy a un accès privilégié, car il peut contourner les contrôles de sécurité. Pour nous assurer que nous pouvons faire confiance au proxy, nous ne laissons que owner 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 sa phrase mnémonique est révélée, nous sommes toujours en sécurité.

    /**
     * @dev Certaines fonctions ne peuvent être appelées que par le proxy.
     */
    modifier onlyProxy {

Il s'agit d'une fonction modifier (opens in a new tab), elle modifie le fonctionnement des autres fonctions.

      require(msg.sender == proxy);

Tout d'abord, vérifier que nous avons été appelés par le proxy et par personne d'autre. Sinon, revert.

      _;
    }

Si c'est le cas, exécuter la fonction que nous modifions.

Ce sont trois opérations qui nécessitent normalement que le message provienne directement de l'entité transférant des jetons ou approuvant une allocation. Ici, nous avons 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. Obtient l'adresse qui serait normalement msg.sender comme paramètre supplémentaire.

CalldataInterpreter.sol

L'interpréteur de calldata est presque identique à celui ci-dessus, à l'exception que les fonctions mandatées reçoivent un paramètre msg.sender et qu'il n'y a pas besoin d'allocation pour transfer.

Test.js

Il y a quelques changements entre le code de test précédent et celui-ci.

const Cdi = await ethers.getContractFactory("CalldataInterpreter")
const cdi = await Cdi.deploy(token.address)
await cdi.deployed()
await token.setProxy(cdi.address)

Nous devons indiquer au contrat ERC-20 à quel proxy faire confiance

console.log("CalldataInterpreter addr:", cdi.address)

// Besoin de deux signataires pour vérifier les allocations
const signers = await ethers.getSigners()
const signer = signers[0]
const poorSigner = signers[1]

Pour vérifier approve() et transferFrom(), nous avons besoin d'un deuxième signataire. Nous l'appelons poorSigner car il ne reçoit aucun de nos jetons (il doit cependant avoir de l'ETH, bien sûr).

// Transfert de jetons
const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
const transferTx = {
  to: cdi.address,
  data: "0x02" + destAddr.slice(2, 42) + "0100",
}
await (await signer.sendTransaction(transferTx)).wait()

Parce que le contrat ERC-20 fait confiance au proxy (cdi), nous n'avons pas besoin d'une allocation pour relayer les transferts.

Tester les deux nouvelles fonctions. Notez que transferFromTx nécessite deux paramètres d'adresse : le donneur de l'allocation et le receveur.

Conclusion

À la fois Optimism (opens in a new tab) et Arbitrum (opens in a new tab) cherchent des moyens de réduire la taille des calldata écrites sur la l1 et donc le coût des transactions. Cependant, en tant que fournisseurs d'infrastructure à la recherche de solutions génériques, nos capacités sont limitées. En tant que développeur de dapp, vous avez des connaissances spécifiques à l'application, ce qui vous permet d'optimiser vos calldata bien mieux que nous ne pourrions le faire dans une solution générique. Espérons que cet article vous aidera à trouver la solution idéale pour vos besoins.

Voir ici pour plus de mes travaux (opens in a new tab).