ABI brevi per l'ottimizzazione dei calldata
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:
- Elaborazione su L2, che di solito è estremamente economica
- 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:
| Sezione | Lunghezza | Byte | Byte sprecati | Gas sprecato | Byte necessari | Gas necessario |
|---|---|---|---|---|---|---|
| Selettore di funzione | 4 | 0-3 | 3 | 48 | 1 | 16 |
| Zeri | 12 | 4-15 | 12 | 48 | 0 | 0 |
| Indirizzo di destinazione | 20 | 16-35 | 0 | 0 | 20 | 320 |
| Importo | 32 | 36-67 | 17 | 64 | 15 | 240 |
| Totale | 68 | 160 | 576 |
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
decimalssiano 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 456 function faucet() external {7 _mint(msg.sender, 1000);8 } // function faucetCalldataInterpreter.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: Unlicense2pragma solidity ^0.8.0;345import { OrisUselessToken } from "./Token.sol";Abbiamo bisogno della funzione del token per sapere come chiamarla.
1contract CalldataInterpreter {23 OrisUselessToken public immutable token;L'indirizzo del token per il quale fungiamo da proxy.
12 /* *3 * @dev Specifica l'indirizzo del token4 * @param tokenAddr_ Indirizzo del contratto ERC-20 */5 6789 constructor(10 address tokenAddr_11 ) {12 token = OrisUselessToken(tokenAddr_);13 } // constructorMostra tuttoL'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;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");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>...).
12 _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.
12 return _retVal;3 }456 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;23 _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:
- Le funzioni che sono
pureoviewnon cambiano lo stato e non costano gas (quando chiamate fuori catena). Non ha senso cercare di ridurre il loro costo del gas. - Le funzioni che si basano su
msg.sender(opens in a new tab). Il valore dimsg.sendersarà l'indirizzo diCalldataInterpreter, 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).
12 // Chiama i metodi che modificano lo stato del token utilizzando3 // le informazioni dal calldata45 // faucet6 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:
| Sezione | Lunghezza | Byte |
|---|---|---|
| Selettore di funzione | 1 | 0 |
| Indirizzo di destinazione | 32 | 1-32 |
| Importo | 2 | 33-34 |
1 } // fallback23} // contract CalldataInterpretertest.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");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()Mostra tuttoIniziamo distribuendo entrambi i contratti.
1 // Ottieni token con cui giocare2 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:
to, l'indirizzo di destinazione. Questo è il contratto dell'interprete dei calldata.data, i calldata da inviare. Nel caso di una chiamata al rubinetto, i dati sono un singolo byte,0x01.
12 }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 correttamente2expect(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 token2const 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()23 // Verifica che abbiamo 256 token in meno4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)56 // E che la nostra destinazione li abbia ricevuti7 expect (await token.balanceOf(destAddr)).to.equal(256)8 }) // it9}) // describeMostra tuttoRidurre 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 CalldataInterpreter2 address owner;34 // L'indirizzo del CalldataInterpreter5 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 456 constructor(7 ) ERC20("Oris useless token-2", "OUT-2") {8 owner = msg.sender;9 }Mostra tuttoL'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 5678 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");1112 proxy = _proxy;13 } // function setProxyMostra tuttoIl 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 456 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 */34 function transferProxy(address from, address to, uint256 amount)5 public virtual onlyProxy() returns (bool)6 {7 _transfer(from, to, amount);8 return true;9 }1011 function approveProxy(address from, address spender, uint256 amount)12 public virtual onlyProxy() returns (bool)13 {14 _approve(from, spender, amount);15 return true;16 }1718 function transferFromProxy(19 address spender,20 address from,21 address to,22 uint256 amount23 ) public virtual onlyProxy() returns (bool)24 {25 _spendAllowance(from, spender, amount);26 _transfer(from, to, amount);27 return true;28 }Mostra tuttoQueste 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:
- È modificata da
onlyProxy()in modo che a nessun altro sia permesso controllarle. - Ottiene l'indirizzo che normalmente sarebbe
msg.sendercome 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 }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 }Mostra tuttoTest.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)23// Sono necessari due firmatari per verificare le allowance4const 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 token2const 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 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// Verifica che la combinazione approve / transferFrom sia stata eseguita correttamente17expect(await token.balanceOf(destAddr2)).to.equal(255)Mostra tuttoTesta 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