Vai al contenuto principale

ABI brevi per l'ottimizzazione dei dati di chiamata

layer 2
Intermedio
Ori Pomerantz
1 aprile 2022
15 minuti di lettura

Introduzione

In questo articolo, imparerai a conoscere i rollup ottimistici, il costo delle transazioni su di essi e come questa diversa struttura dei costi ci richieda di ottimizzare per elementi diversi rispetto alla Mainnet di Ethereum. Imparerai anche come implementare questa ottimizzazione.

Divulgazione completa

Sono un dipendente a tempo pieno di Optimism (opens in a new tab), quindi gli esempi in questo articolo verranno eseguiti su Optimism. Tuttavia, la tecnica spiegata qui dovrebbe funzionare altrettanto bene per altri rollup.

Terminologia

Quando si discute dei rollup, il termine 'layer 1 (L1)' viene utilizzato per la Mainnet, la rete Ethereum di produzione. Il termine 'layer 2 (L2)' viene utilizzato per il rollup o qualsiasi altro sistema che si affida al layer 1 per la sicurezza ma esegue la maggior parte della sua elaborazione offchain.

Come possiamo ridurre ulteriormente il costo delle transazioni sul layer 2?

I rollup ottimistici devono conservare un registro di ogni transazione storica in modo che chiunque possa esaminarle e verificare che lo stato attuale sia corretto. Il modo più economico per inserire dati nella Mainnet di Ethereum è scriverli come dati di chiamata. Questa soluzione è stata scelta sia da Optimism (opens in a new tab) che da Arbitrum (opens in a new tab).

Costo delle transazioni sul layer 2

Il costo delle transazioni sul layer 2 è composto da due componenti:

  1. L'elaborazione sul layer 2, che di solito è estremamente economica
  2. L'archiviazione sul layer 1, che è legata ai costi del gas della Mainnet

Nel momento in cui scrivo, su Optimism il costo del gas sul layer 2 è di 0,001 Gwei. Il costo del gas sul layer 1, d'altra parte, è di circa 40 Gwei. Puoi vedere i prezzi attuali qui (opens in a new tab).

Un byte di dati di chiamata costa 4 gas (se è zero) o 16 gas (se è qualsiasi altro valore). Una delle operazioni più costose sull'EVM è la scrittura nell'archiviazione. Il costo massimo per scrivere una parola di 32 byte nell'archiviazione sul layer 2 è di 22100 gas. Attualmente, questo equivale a 22,1 Gwei. Quindi, se riusciamo a risparmiare un singolo byte zero di dati di chiamata, saremo in grado di scrivere circa 200 byte nell'archiviazione e trarne comunque vantaggio.

L'ABI

La stragrande maggioranza delle transazioni accede a un contratto da un account di proprietà esterna. La maggior parte dei contratti è scritta in Solidity e interpreta il proprio campo dati secondo l'interfaccia binaria dell'applicazione (ABI) (opens in a new tab).

Tuttavia, l'ABI è stata progettata per il layer 1, dove un byte di dati di chiamata costa all'incirca quanto quattro operazioni aritmetiche, non per il layer 2 dove un byte di dati di chiamata costa più di mille operazioni aritmetiche. I dati di chiamata sono divisi in questo modo:

SezioneLunghezzaByteByte sprecatiGas sprecatoByte necessariGas necessario
Selettore di funzione40-3348116
Zeri124-15124800
Indirizzo di destinazione2016-350020320
Importo3236-67176415240
Totale68160576

Spiegazione:

  • Selettore di funzione: Il contratto ha meno di 256 funzioni, quindi possiamo distinguerle con un singolo byte. Questi byte sono in genere diversi da zero e pertanto costano sedici gas (opens in a new tab).
  • Zeri: Questi byte sono sempre zero perché un indirizzo di venti byte non richiede una parola di trentadue byte per contenerlo. I byte che contengono zero costano quattro gas (vedi lo yellow paper (opens in a new tab), Appendice G, p. 27, il valore per Gtxdatazero).
  • Importo: Se supponiamo che in questo contratto decimals sia diciotto (il valore normale) e l'importo massimo di token che trasferiamo sarà 1018, otteniamo un importo massimo di 1036. 25615 > 1036, quindi quindici byte sono sufficienti.

Uno spreco di 160 gas sul layer 1 è normalmente trascurabile. Una transazione costa almeno 21.000 gas (opens in a new tab), quindi uno 0,8% in più non ha importanza. Tuttavia, sul layer 2, le cose sono diverse. Quasi l'intero costo della transazione è dovuto alla sua scrittura sul layer 1. Oltre ai dati di chiamata della transazione, ci sono 109 byte di intestazione della transazione (indirizzo di destinazione, firma, ecc.). Il costo totale è quindi 109*16+576+160=2480 e ne stiamo sprecando circa il 6,5%.

Ridurre i costi quando non controlli la destinazione

Supponendo che tu non abbia il controllo sul contratto di destinazione, puoi comunque utilizzare una soluzione simile a questa (opens in a new tab). Esaminiamo i file pertinenti.

Token.sol

Questo è il contratto di destinazione (opens in a new tab). È un contratto ERC-20 standard, con una funzionalità aggiuntiva. Questa funzione faucet consente a qualsiasi utente di ottenere dei token da utilizzare. Renderebbe inutile un contratto ERC-20 di produzione, ma semplifica la vita quando un ERC-20 esiste solo per facilitare i test.

    /**
     * @dev Dà al chiamante 1000 token con cui giocare
     */
    function faucet() external {
        _mint(msg.sender, 1000);
    }   // function faucet

CalldataInterpreter.sol

Questo è il contratto che le transazioni dovrebbero chiamare con dati di chiamata più brevi (opens in a new tab). Esaminiamolo riga per riga.

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


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

Abbiamo bisogno della funzione del token per sapere come chiamarla.

contract CalldataInterpreter {

    OrisUselessToken public immutable token;

L'indirizzo del token per il quale siamo un proxy.

L'indirizzo del token è l'unico parametro che dobbiamo specificare.

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

Leggere un valore dai dati di chiamata.

        uint _retVal;

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

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

Caricheremo una singola parola di 32 byte (256 bit) in memoria e rimuoveremo i byte che non fanno parte del campo che desideriamo. Questo algoritmo non funziona per valori più lunghi di 32 byte e, naturalmente, non possiamo leggere oltre la fine dei dati di chiamata. Sul layer 1 potrebbe essere necessario saltare questi test per risparmiare sul gas, ma sul layer 2 il gas è estremamente economico, il che consente qualsiasi controllo di integrità ci venga in mente.

        assembly {
            _retVal := calldataload(startByte)
        }

Avremmo potuto copiare i dati dalla chiamata a fallback() (vedi sotto), ma è più facile usare Yul (opens in a new tab), il linguaggio assembly dell'EVM.

Qui usiamo il codice operativo (opcode) CALLDATALOAD (opens in a new tab) per leggere i byte da startByte a startByte+31 nello stack. In generale, la sintassi di un codice operativo (opcode) in Yul è <opcode name>(<first stack value, if any>,<second stack value, if any>...).


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

Solo i byte length più significativi fanno parte del campo, quindi eseguiamo uno scorrimento a destra (right-shift) (opens in a new tab) per sbarazzarci degli altri valori. Questo ha l'ulteriore vantaggio di spostare il valore a destra del campo, in modo che sia il valore stesso piuttosto che il valore moltiplicato per 256qualcosa.


        return _retVal;
    }


    fallback() external {

Quando una chiamata a un contratto Solidity non corrisponde a nessuna delle firme di funzione, chiama la funzione fallback() (opens in a new tab) (supponendo che ce ne sia una). Nel caso di CalldataInterpreter, qualsiasi chiamata arriva qui perché non ci sono altre funzioni external o public.

        uint _func;

        _func = calldataVal(0, 1);

Legge il primo byte dei dati di chiamata, che ci indica la funzione. Ci sono due motivi per cui una funzione non sarebbe disponibile qui:

  1. Le funzioni che sono pure o view non cambiano lo stato e non costano gas (quando chiamate offchain). Non ha senso cercare di ridurre il loro costo in gas.
  2. Le funzioni che si basano su msg.sender (opens in a new tab). Il valore di msg.sender sarà l'indirizzo di CalldataInterpreter, non il chiamante.

Sfortunatamente, guardando le specifiche ERC-20 (opens in a new tab), questo lascia solo una funzione, transfer. Questo ci lascia con solo due funzioni: transfer (perché possiamo chiamare transferFrom) e faucet (perché possiamo trasferire i token a chiunque ci abbia chiamato).


        // Chiama i metodi che modificano lo stato del token usando
        // le informazioni dai dati di chiamata

        // faucet
        if (_func == 1) {

Una chiamata a faucet(), che non ha parametri.

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

Dopo aver chiamato token.faucet() otteniamo dei token. Tuttavia, come contratto proxy, non abbiamo bisogno di token. L'EOA (account di proprietà esterna) o il contratto che ci ha chiamato ne ha bisogno. Quindi trasferiamo tutti i nostri token a chiunque ci abbia chiamato.

        // trasferimento (supponendo di avere un'autorizzazione di spesa per esso)
        if (_func == 2) {

Il trasferimento di token richiede due parametri: l'indirizzo di destinazione e l'importo.

            token.transferFrom(
                msg.sender,

Consentiamo ai chiamanti di trasferire solo i token che possiedono

                address(uint160(calldataVal(1, 20))),

L'indirizzo di destinazione inizia al byte #1 (il byte #0 è la funzione). Come indirizzo, è lungo 20 byte.

                calldataVal(21, 2)

Per questo particolare contratto supponiamo che il numero massimo di token che chiunque vorrebbe trasferire rientri in due byte (meno di 65536).

            );
        }

Nel complesso, un trasferimento richiede 35 byte di dati di chiamata:

SezioneLunghezzaByte
Selettore di funzione10
Indirizzo di destinazione321-32
Importo233-34
    }   // fallback

}       // contract CalldataInterpreter

test.js

Questo unit test in JavaScript (opens in a new tab) ci mostra come utilizzare questo meccanismo (e come verificare che funzioni correttamente). Darò per scontato che tu conosca chai (opens in a new tab) ed ethers (opens in a new tab) e spiegherò solo le parti che si applicano specificamente al contratto.

Iniziamo distribuendo entrambi i contratti.

    // Ottieni token con cui giocare
    const faucetTx = {

Non possiamo usare le funzioni di alto livello che useremmo normalmente (come token.faucet()) per creare transazioni, perché non seguiamo l'ABI. Invece, dobbiamo costruire la transazione noi stessi e poi inviarla.

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

Ci sono due parametri che dobbiamo fornire per la transazione:

  1. to, l'indirizzo di destinazione. Questo è il contratto dell'interprete dei dati di chiamata.
  2. data, i dati di chiamata da inviare. Nel caso di una chiamata al faucet, i dati sono un singolo byte, 0x01.

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

Chiamiamo il metodo sendTransaction del firmatario (opens in a new tab) perché abbiamo già specificato la destinazione (faucetTx.to) e abbiamo bisogno che la transazione sia firmata.

// Verifica che il faucet fornisca i token correttamente
expect(await token.balanceOf(signer.address)).to.equal(1000)

Qui verifichiamo il saldo. Non c'è bisogno di risparmiare gas sulle funzioni view, quindi le eseguiamo normalmente.

// Dai al CDI un'autorizzazione di spesa (le approvazioni non possono essere passate tramite proxy)
const approveTX = await token.approve(cdi.address, 10000)
await approveTX.wait()
expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

Fornire all'interprete dei dati di chiamata un'autorizzazione di spesa per poter effettuare trasferimenti.

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

Creare una transazione di trasferimento. Il primo byte è "0x02", seguito dall'indirizzo di destinazione e infine dall'importo (0x0100, che è 256 in decimale).

Ridurre il costo quando hai il controllo sul contratto di destinazione

Se hai il controllo sul contratto di destinazione, puoi creare funzioni che bypassano i controlli di msg.sender perché si fidano dell'interprete dei dati di chiamata. Puoi vedere un esempio di come funziona qui, nel branch control-contract (opens in a new tab).

Se il contratto rispondesse solo a transazioni esterne, potremmo cavarcela avendo un solo contratto. Tuttavia, ciò interromperebbe la componibilità. È molto meglio avere un contratto che risponde alle normali chiamate ERC-20 e un altro contratto che risponde alle transazioni con dati di chiamata brevi.

Token.sol

In questo esempio possiamo modificare Token.sol. Questo ci consente di avere una serie di funzioni che solo il proxy può chiamare. Ecco le nuove parti:

    // L'unico indirizzo autorizzato a specificare l'indirizzo del CalldataInterpreter
    address owner;

    // L'indirizzo del CalldataInterpreter
    address proxy = address(0);

Il contratto ERC-20 deve conoscere l'identità del proxy autorizzato. Tuttavia, non possiamo impostare questa variabile nel costruttore, perché non ne conosciamo ancora il valore. Questo contratto viene istanziato per primo perché il proxy si aspetta l'indirizzo del token nel suo costruttore.

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

L'indirizzo del creatore (chiamato owner) è memorizzato qui perché è l'unico indirizzo autorizzato a impostare il proxy.

Il proxy ha un accesso privilegiato, perché può bypassare i controlli di sicurezza. Per assicurarci di poterci fidare del proxy, permettiamo solo a owner di chiamare questa funzione, e solo una volta. Una volta che proxy ha un valore reale (non zero), quel valore non può cambiare, quindi anche se il proprietario decide di diventare disonesto, o la sua frase mnemonica viene rivelata, siamo comunque al sicuro.

    /**
     * @dev Alcune funzioni possono essere chiamate solo dal proxy.
     */
    modifier onlyProxy {

Questa è una funzione modifier (opens in a new tab), modifica il modo in cui funzionano le altre funzioni.

      require(msg.sender == proxy);

Innanzitutto, verifica che siamo stati chiamati dal proxy e da nessun altro. In caso contrario, revert.

      _;
    }

In tal caso, esegui la funzione che modifichiamo.

Queste sono tre operazioni che normalmente richiedono che il messaggio provenga direttamente dall'entità che trasferisce i token o che approva un'autorizzazione di spesa. Qui abbiamo una versione proxy di queste operazioni che:

  1. È modificata da onlyProxy() in modo che a nessun altro sia consentito controllarle.
  2. Ottiene l'indirizzo che normalmente sarebbe msg.sender come parametro aggiuntivo.

CalldataInterpreter.sol

L'interprete dei dati di chiamata è quasi identico a quello precedente, tranne per il fatto che le funzioni proxy ricevono un parametro msg.sender e non c'è bisogno di un'autorizzazione di spesa per transfer.

Test.js

Ci sono alcune modifiche tra il codice di test precedente e questo.

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

Dobbiamo dire al contratto ERC-20 di quale proxy fidarsi

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

// Servono due firmatari per verificare le autorizzazioni di spesa
const signers = await ethers.getSigners()
const signer = signers[0]
const poorSigner = signers[1]

Per controllare approve() e transferFrom() abbiamo bisogno di un secondo firmatario. Lo chiamiamo poorSigner perché non ottiene nessuno dei nostri token (deve avere ETH, ovviamente).

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

Poiché il contratto ERC-20 si fida del proxy (cdi), non abbiamo bisogno di un'autorizzazione di spesa per inoltrare i trasferimenti.

Testa le due nuove funzioni. Nota che transferFromTx richiede due parametri di indirizzo: chi fornisce l'autorizzazione di spesa e chi la riceve.

Conclusione

Sia Optimism (opens in a new tab) che Arbitrum (opens in a new tab) stanno cercando modi per ridurre le dimensioni dei dati di chiamata scritti sul layer 1 e quindi il costo delle transazioni. Tuttavia, come fornitori di infrastrutture alla ricerca di soluzioni generiche, le nostre capacità sono limitate. Come sviluppatore di dapp, hai una conoscenza specifica dell'applicazione, che ti consente di ottimizzare i tuoi dati di chiamata molto meglio di quanto potremmo fare noi in una soluzione generica. Speriamo che questo articolo ti aiuti a trovare la soluzione ideale per le tue esigenze.

Vedi qui per altri miei lavori (opens in a new tab).

Ultimo aggiornamento della pagina: 3 aprile 2026