Vai al contenuto principale

ABI brevi per l'ottimizzazione dei calldata

livello 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 cose diverse rispetto alla rete principale di Ethereum. Imparerai anche come implementare questa ottimizzazione.

Piena trasparenza

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 'livello 1' (L1) è usato per la rete principale, la rete Ethereum di produzione. Il termine 'livello 2' (L2) è usato per il rollup o qualsiasi altro sistema che si affida a L1 per la sicurezza ma esegue la maggior parte della sua elaborazione fuori catena.

Come possiamo ridurre ulteriormente il costo delle transazioni su L2?

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 rete principale di Ethereum è scriverli come calldata. Questa soluzione è stata scelta sia da Optimism (opens in a new tab) che da Arbitrum (opens in a new tab).

Costo delle transazioni su L2

Il costo delle transazioni su L2 è composto da due componenti:

  1. Elaborazione su L2, che di solito è estremamente economica
  2. Archiviazione su L1, che è legata ai costi del gas della rete principale

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

Un byte di calldata costa 4 gas (se è zero) o 16 gas (se è qualsiasi altro valore). Una delle operazioni più costose sulla macchina virtuale di Ethereum è la scrittura nell'archiviazione. Il costo massimo per scrivere una parola di 32 byte nell'archiviazione su L2 è di 22100 gas. Attualmente, questo equivale a 22,1 gwei. Quindi, se riusciamo a risparmiare un singolo byte zero di calldata, 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 controllato esternamente. 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 L1, dove un byte di calldata costa all'incirca quanto quattro operazioni aritmetiche, non per L2 dove un byte di calldata costa più di mille operazioni aritmetiche. I calldata 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 tipicamente 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 i decimals siano diciotto (il valore normale) e l'importo massimo di token che trasferiremo sarà 1018, otteniamo un importo massimo di 1036. 25615 > 1036, quindi quindici byte sono sufficienti.

Uno spreco di 160 gas su L1 è normalmente trascurabile. Una transazione costa almeno 21.000 gas (opens in a new tab), quindi uno 0,8% in più non ha importanza. Tuttavia, su L2, le cose sono diverse. Quasi l'intero costo della transazione è scriverla su L1. Oltre ai calldata 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 si controlla 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 rilevanti.

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.

1 /* *
2 * @dev Fornisce al chiamante 1000 token con cui giocare */
3
4
5
6 function faucet() external {
7 _mint(msg.sender, 1000);
8 } // function faucet

CalldataInterpreter.sol

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

1// SPDX-License-Identifier: Unlicense
2pragma solidity ^0.8.0;
3
4
5import { OrisUselessToken } from "./Token.sol";

Abbiamo bisogno della funzione del token per sapere come chiamarla.

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;

L'indirizzo del token per il quale fungiamo da proxy.

1
2 /* *
3 * @dev Specifica l'indirizzo del token
4 * @param tokenAddr_ Indirizzo del contratto ERC-20 */
5
6
7
8
9 constructor(
10 address tokenAddr_
11 ) {
12 token = OrisUselessToken(tokenAddr_);
13 } // constructor
Mostra tutto

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

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

Leggere un valore dai calldata.

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");

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 calldata. Su L1 potrebbe essere necessario saltare questi test per risparmiare sul gas, ma su L2 il gas è estremamente economico, il che consente qualsiasi controllo di integrità ci venga in mente.

1 assembly {
2 _retVal := calldataload(startByte)
3 }

Avremmo potuto copiare i dati dalla chiamata a fallback() (vedi sotto), ma è più facile usare Yul (opens in a new tab), il linguaggio assembly della macchina virtuale di Ethereum.

Qui usiamo l'opcode CALLDATALOAD (opens in a new tab) per leggere i byte da startByte a startByte+31 nello stack. In generale, la sintassi di un opcode in Yul è <nome opcode>(<primo valore dello stack, se presente>,<secondo valore dello stack, se presente>...).

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

Solo i byte di length più significativi fanno parte del campo, quindi eseguiamo uno scorrimento a destra (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.

1
2 return _retVal;
3 }
4
5
6 fallback() external {

Quando una chiamata a un contratto Solidity non corrisponde a nessuna delle firme delle funzioni, 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.

1 uint _func;
2
3 _func = calldataVal(0, 1);

Legge il primo byte dei calldata, 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 fuori catena). Non ha senso cercare di ridurre il loro costo del 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 indietro a chi ci ha chiamato).

1
2 // Chiama i metodi che modificano lo stato del token utilizzando
3 // le informazioni dal calldata
4
5 // faucet
6 if (_func == 1) {

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

1 token.faucet();
2 token.transfer(msg.sender,
3 token.balanceOf(address(this)));
4 }

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

1 // transfer (supponiamo di avere un'allowance per questo)
2 if (_func == 2) {

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

1 token.transferFrom(
2 msg.sender,

Consentiamo ai chiamanti di trasferire solo i token che possiedono

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

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

1 calldataVal(21, 2)

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

1 );
2 }

Nel complesso, un trasferimento richiede 35 byte di calldata:

SezioneLunghezzaByte
Selettore di funzione10
Indirizzo di destinazione321-32
Importo233-34
1 } // fallback
2
3} // contract CalldataInterpreter

test.js

Questo test unitario in JavaScript (opens in a new tab) ci mostra come utilizzare questo meccanismo (e come verificare che funzioni correttamente). Supporrò 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.

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()
Mostra tutto

Iniziamo distribuendo entrambi i contratti.

1 // Ottieni token con cui giocare
2 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.

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

Ci sono due parametri che dobbiamo fornire per la transazione:

  1. to, l'indirizzo di destinazione. Questo è il contratto dell'interprete dei calldata.
  2. data, i calldata da inviare. Nel caso di una chiamata al rubinetto, i dati sono un singolo byte, 0x01.
1
2 }
3 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.

1// Verifica che il faucet fornisca i token correttamente
2expect(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.

1// Fornisci un'allowance al CDI (le approvazioni non possono essere gestite tramite proxy)
2const approveTX = await token.approve(cdi.address, 10000)
3await approveTX.wait()
4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

Fornisce all'interprete dei calldata un'autorizzazione per poter effettuare trasferimenti.

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

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

1 await (await signer.sendTransaction(transferTx)).wait()
2
3 // Verifica che abbiamo 256 token in meno
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // E che la nostra destinazione li abbia ricevuti
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe
Mostra tutto

Ridurre il costo quando si controlla il contratto di destinazione

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

Se il contratto rispondesse solo a transazioni esterne, potremmo cavarcela con 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 permette di avere una serie di funzioni che solo il proxy può chiamare. Ecco le nuove parti:

1 // L'unico indirizzo autorizzato a specificare l'indirizzo del CalldataInterpreter
2 address owner;
3
4 // L'indirizzo del CalldataInterpreter
5 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.

1 /* *
2 * @dev Chiama il costruttore ERC20. */
3
4
5
6 constructor(
7 ) ERC20("Oris useless token-2", "OUT-2") {
8 owner = msg.sender;
9 }
Mostra tutto

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

1 /* *
2 * @dev imposta l'indirizzo per il proxy (il CalldataInterpreter).
3 * Può essere chiamato solo una volta dal proprietario */
4
5
6
7
8 function setProxy(address _proxy) external {
9 require(msg.sender == owner, "Can only be called by owner");
10 require(proxy == address(0), "Proxy is already set");
11
12 proxy = _proxy;
13 } // function setProxy
Mostra tutto

Il proxy ha un accesso privilegiato, perché può aggirare i controlli di sicurezza. Per assicurarci di potercene fidare, permettiamo solo a owner di chiamare questa funzione, e solo una volta. Una volta che proxy ha un valore reale (diverso da 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.

1 /* *
2 * @dev Alcune funzioni possono essere chiamate solo dal proxy. */
3
4
5
6 modifier onlyProxy {

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

1 require(msg.sender == proxy);

Per prima cosa, verifica che siamo stati chiamati dal proxy e da nessun altro. In caso contrario, esegue un revert.

1 _;
2 }

In caso affermativo, esegue la funzione che modifichiamo.

1 /* Funzioni che consentono al proxy di fungere effettivamente da proxy per gli account */
2 /* Functions that allow the proxy to actually proxy for accounts */
3
4 function transferProxy(address from, address to, uint256 amount)
5 public virtual onlyProxy() returns (bool)
6 {
7 _transfer(from, to, amount);
8 return true;
9 }
10
11 function approveProxy(address from, address spender, uint256 amount)
12 public virtual onlyProxy() returns (bool)
13 {
14 _approve(from, spender, amount);
15 return true;
16 }
17
18 function transferFromProxy(
19 address spender,
20 address from,
21 address to,
22 uint256 amount
23 ) public virtual onlyProxy() returns (bool)
24 {
25 _spendAllowance(from, spender, amount);
26 _transfer(from, to, amount);
27 return true;
28 }
Mostra tutto

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

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

CalldataInterpreter.sol

L'interprete dei calldata è 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 per il transfer.

1 // transfer (nessun bisogno di 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 }
Mostra tutto

Test.js

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

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

Dobbiamo dire al contratto ERC-20 di quale proxy fidarsi

1console.log("CalldataInterpreter addr:", cdi.address)
2
3// Sono necessari due firmatari per verificare le allowance
4const signers = await ethers.getSigners()
5const signer = signers[0]
6const 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).

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

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

1// approval e 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// Verifica che la combinazione approve / transferFrom sia stata eseguita correttamente
17expect(await token.balanceOf(destAddr2)).to.equal(255)
Mostra tutto

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

Conclusione

Sia Optimism (opens in a new tab) che Arbitrum (opens in a new tab) stanno cercando modi per ridurre le dimensioni dei calldata scritti su L1 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 calldata 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: 22 agosto 2025

Questo tutorial è stato utile?