ABI brevi per l'ottimizzazione dei calldata
Introduzione
In questo articolo conoscerai i rollup ottimistici, il costo delle transazioni su di essi e come tale diversa struttura di costo ci imponga di ottimizzare diversi aspetti rispetto alla Rete principale di Ethereum. Imparerai anche come implementare quest'ottimizzazione.
Divulgazione completa
Sono un dipendente a tempo pieno di Optimism(opens in a new tab), quindi gli esempi in questo articolo saranno eseguiti su Optimism. Tuttavia la tecnica qui spiegata dovrebbe funzionare altrettanto bene per altri rollup.
Terminologia
Parlando di rollup, il termine "livello 1" (L1) è usato per la Rete principale, la rete di produzione di Ethereum. Il termine "livello 2" (L2) è usato per il rollup o qualsiasi altro sistema che si basa sul L1 per la sicurezza ma svolge gran parte della sua elaborazione al di fuori della catena.
Come possiamo ridurre ulteriormente il costo delle transazioni su L2?
I Rollup ottimistici devono conservare un registro di ogni transazione storica, così che chiunque possa consultarlo e verificare che lo stato corrente sia corretto. Il metodo 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 ha due componenti:
- Elaborazione su L2, solitamente estremamente economica
- Archiviazione sul L1, legata ai costi del gas della Rete Principale
Al momento della redazione, su Optimism il costo del gas del L2 è 0,001 Gwei. Il costo del gas del L1, d'altra parte, è approssimativamente di 40 gwei. Puoi visualizzare i prezzi correnti qui(opens in a new tab).
Un byte di dati di chiamata costa 4 gas (se è zero) o 16 gas (se ha qualsiasi altro valore). Una delle operazioni più costose sull'EVM è scrivere in memoria. Il costo massimo della scrittura di una parola di 32 byte all'archiviazione sul L2, è di 22.100 gas. Attualmente, ciò equivale a 22,1 gwei. Quindi, se possiamo risparmiare un singolo byte zero di calldata, potremo scrivere circa 200 byte in memoria e ne usciremo comunque bene.
L'ABI
La stragrande maggioranza delle transazioni, accede a un contratto da un conto posseduto esternamente. Gran parte dei contratti è scritta in Solidity e interpreta il proprio campo dei dati secondo l'interfaccia binaria dell'applicazione (Application Binary Interface – ABI)(opens in a new tab).
Tuttavia, l'ABI è stata progettata per il L1, dove un byte di calldata costa approssimativamente quanto quattro operazioni aritmetiche, non per il L2 dove un byte di calldata costa più di un migliaio di operazioni aritmetiche. Ad esempio, ecco una transazione di trasferimento ERC-20(opens in a new tab). I calldata sono divisi come segue:
Sezione | Lunghezza | Byte | Byte sprecati | Gas sprecato | Byte necessari | Gas necessario |
---|---|---|---|---|---|---|
Selettore della 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 della funzione: il contratto ha meno di 256 funzioni, quindi, possiamo distinguerle con un solo byte. Questi byte sono tipicamente diversi da zero e, dunque, 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. I byte contenenti zero costano quattro gas (vedi lo yellowpaper(opens in a new tab), Appendice G, p. 27, il valore per
G
txdatazero
). - 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 L1 è di norma trascurabile. Una transazione costa almeno 21.000 gas(opens in a new tab), quindi un ulteriore 0,8% non conta. Tuttavia, sul L2 le cose sono diverse. Quasi l'intero costo della transazione deriva dalla scrittura sul L1. Oltre ai calldata della transazione, ci sono 109 byte di intestazione della transazione (indirizzo di destinazione, firma, ecc.). Il costo totale è dunque 109*16+576+160=2480
, e ne stiamo sprecando circa il 6,5%.
Ridurre i costi quando non controlli la destinazione
Supponendo di non avere il controllo sul contratto di destinazione, puoi comunque usare una soluzione simile a questa(opens in a new tab). Vediamo 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 usare. Renderebbe inutile una produzione del contratto ERC-20, ma semplifica la vita quando l'ERC-20 esiste solo per facilitare i test.
1 /**2 * @dev Gives the caller 1000 tokens to play with3 */4 function faucet() external {5 _mint(msg.sender, 1000);6 } // function faucetCopia
Puoi vedere un esempio di questo contratto distribuito qui(opens in a new tab).
CalldataInterpreter.sol
Questo è il contratto che le transazioni dovrebbero chiamare con calldata più brevi(opens in a new tab). Analizziamolo riga per riga.
1//SPDX-License-Identifier: Unlicense2pragma solidity ^0.8.0;345import { OrisUselessToken } from "./Token.sol";Copia
Ci serve la funzione del token per sapere come chiamarla.
1contract CalldataInterpreter {23 OrisUselessToken public immutable token;Copia
L'indirizzo del token per cui siamo un proxy.
12 /**3 * @dev Specify the token address4 * @param tokenAddr_ ERC-20 contract address5 */6 constructor(7 address tokenAddr_8 ) {9 token = OrisUselessToken(tokenAddr_);10 } // constructorMostra tuttoCopia
L'indirizzo del token è l'unico parametro che dobbiamo specificare.
1 function calldataVal(uint startByte, uint length)2 private pure returns (uint) {Copia
Leggi 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");Copia
Caricheremo in memoria un'unica parola da 32 byte (256 bit) e rimuoveremo i byte che non fanno parte del campo che vogliamo. Questo algoritmo non funziona per valori più lunghi di 32 byte e, ovviamente, non possiamo leggere oltre il termine dei calldata. Sul L1, potrebbe esser necessario saltare questi test per risparmiare sul gas, ma sul L2, il gas è estremamente economico, consentendo qualsiasi controllo d'integrità immaginabile.
1 assembly {2 _retVal := calldataload(startByte)3 }Copia
Potremmo aver copiato 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 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 su Yul è <opcode name>(<first stack value, if any>,<second stack value, if any>...)
.
12 _retVal = _retVal >> (256-length*8);Copia
Solo i byte length
più significativi fanno parte del campo, quindi effettuiamo uno spostamento a destra(opens in a new tab) per liberarci degli altri valori. Questo ha il vantaggio aggiuntivo di spostare il valore a destra del campo, quindi è il valore stesso invece del valore moltiplicato per 256qualcosa.
12 return _retVal;3 }456 fallback() external {Copia
Quando una chiamata a un contratto in Solidity non corrisponde ad alcuna delle firme della funzione, chiama la funzione fallback()
(opens in a new tab) (supponendo che ne esista una). Nel caso di CalldataInterpreter
, qualsiasi chiamata arriva qui perché non vi sono altre funzioni external
o public
.
1 uint _func;23 _func = calldataVal(0, 1);Copia
Leggi il primo byte dei calldata, che ci dice la funzione. Ci sono due motivi per cui una funzione potrebbe non essere disponibile qui:
- Le funzioni che sono
pure
oview
non cambiano lo stato e non costano gas (quando chiamate al di fuori della catena). Non ha senso provare a ridurne il loro costo del gas. - Le funzioni che si affidano a
msg.sender
(opens in a new tab). Il valore delmsg.sender
sarà l'indirizzoCalldataInterpreter
, non il chiamante.
Sfortunatamente, guardando alle specifiche dell'ERC-20(opens in a new tab), questo lascia solo una funzione: transfer
. Questo ci lascia con soltanto due funzioni: transfer
(poiché possiamo chiamare transferFrom
) e faucet
(poiché possiamo ritrasferire i token a chiunque ci abbia chiamati).
12 // Call the state changing methods of token using3 // information from the calldata45 // faucet6 if (_func == 1) {Copia
Una chiamata a faucet()
, priva di parametri.
1 token.faucet();2 token.transfer(msg.sender,3 token.balanceOf(address(this)));4 }Copia
Dopo aver chiamato token.faucet()
otteniamo i token. Tuttavia, come contratto proxy, non necessitiamo di token. L'EOA (conto posseduto esternamente) o il contratto che ci ha chiamati, sì. Quindi trasferiamo tutti i nostri token a chiunque ci abbia chiamati.
1 // transfer (assume we have an allowance for it)2 if (_func == 2) {Copia
Il trasferimento dei token richiede due parametri: l'indirizzo di destinazione e l'importo.
1 token.transferFrom(2 msg.sender,Copia
Consentiamo solo ai chiamanti di trasferire i token che possiedono
1 address(uint160(calldataVal(1, 20))),Copia
L'indirizzo di destinazione inizia al byte #1 (il byte #0 è la funzione). Come indirizzo, è lungo 20 byte.
1 calldataVal(21, 2)Copia
Per questo contratto specifico supponiamo che il numero massimo di token che chiunque voglia trasferire entri in due byte (meno di 65536).
1 );2 }Copia
In generale, un trasferimento richiede 35 byte di calldata:
Sezione | Lunghezza | Byte |
---|---|---|
Selettore della funzione | 1 | 0 |
Indirizzo di destinazione | 32 | 1-32 |
Importo | 2 | 33-34 |
1 } // fallback23} // contract CalldataInterpreterCopia
test.js
Questo test unitario di JavaScript(opens in a new tab) ci mostra come usare questo meccanismo (e come verificare che funzioni correttamente). Partirò dal presupposto che tu comprenda chai(opens in a new tab) ed ether(opens in a new tab) e spiegherò solo le parti che si applicano nello specifico 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 tuttoCopia
Iniziamo distribuendo entrambi i contratti.
1 // Get tokens to play with2 const faucetTx = {
Non possiamo usare le funzioni di alto livello che useremmo normalmente (come token.faucet()
) per creare le transazioni, perché non seguiamo l'ABI. Invece, dobbiamo costruire noi stessi la transazione 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 faucet, i dati sono in 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 ci serve che la transazione sia firmata.
1// Check the faucet provides the tokens correctly2expect(await token.balanceOf(signer.address)).to.equal(1000)
Qui verifichiamo il saldo. Non serve risparmiare gas sulle funzioni view
, quindi le eseguiamo normalmente.
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)
Dà all'interprete dei calldata un'indennità per poter effettuare trasferimenti.
1// Transfer tokens2const 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, ovvero 256 in decimale).
1 await (await signer.sendTransaction(transferTx)).wait()23 // Check that we have 256 tokens less4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)56 // And that our destination got them7 expect (await token.balanceOf(destAddr)).to.equal(256)8 }) // it9}) // describeMostra tutto
Esempio
Se desideri vedere questi file in azione senza eseguirli tu stesso, segui questi link:
- Distribuzione di
OrisUselessToken
(opens in a new tab) all'indirizzo0x950c753c0edbde44a74d3793db738a318e9c8ce8
(opens in a new tab). - Distribuzione di
CalldataInterpreter
(opens in a new tab) all'indirizzo0x16617fea670aefe3b9051096c0eb4aeb4b3a5f55
(opens in a new tab). - Chiamata a
faucet()
(opens in a new tab). - Chiamata a
OrisUselessToken.approve()
(opens in a new tab). Questa chiamata deve andare direttamente al contratto del token, poiché l'elaborazione si affida almsg.sender
. - Chiamata a
transfer()
(opens in a new tab).
Ridurre il costo quando hai il controllo del contratto di destinazione
Se hai il controllo sul contratto di destinazione, puoi creare funzioni che bypassano i controlli msg.sender
poiché 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 alle transazioni esterne, potremmo riuscirsi con un solo contratto. Tuttavia, questo spezzerebbe la componibilità. È molto meglio avere un contratto che risponda alle normali chiamate ERC-20 e un altro che risponda alle transazioni con dati della chiamata brevi.
Token.sol
In questo esempio, possiamo modificare Token.sol
. Questo ci permette di avere un numero di funzioni che solo il proxy può chiamare. Ecco le nuove parti:
1 // The only address allowed to specify the CalldataInterpreter address2 address owner;34 // The CalldataInterpreter address5 address proxy = address(0);Copia
Il contratto ERC-20 deve conoscere l'identità del proxy autorizzato. Tuttavia, non possiamo impostare questa variabile nel costruttore, perché non conosciamo ancora il valore. Questo contratto è stato istanziato subito poiché il proxy si aspetta che l'indirizzo del token sia nel suo costruttore.
1 /**2 * @dev Calls the ERC20 constructor.3 */4 constructor(5 ) ERC20("Oris useless token-2", "OUT-2") {6 owner = msg.sender;7 }Copia
L'indirizzo del creatore (chiamato owner
) è memorizzato qui perché è l'unico indirizzo autorizzato a impostare il proxy.
1 /**2 * @dev set the address for the proxy (the CalldataInterpreter).3 * Can only be called once by the owner4 */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");89 proxy = _proxy;10 } // function setProxyMostra tuttoCopia
Il proxy ha accesso privilegiato, perché può bypassare i controlli di sicurezza. Per essere certi di poterci fidare del proxy, l'unico che può chiamare questa funzione è l'owner
, e solo una volta. Una volta che proxy
ha un valore reale (non zero), quel valore non può cambiare, quindi anche se il proprietario diventa malevolo, o la sua mnemonica viene rivelata, siamo comunque al sicuro.
1 /**2 * @dev Some functions may only be called by the proxy.3 */4 modifier onlyProxy {Copia
Questa è una funzione modifier
(opens in a new tab), ossia modifica come funzionano le altre funzioni.
1 require(msg.sender == proxy);Copia
In primo luogo, verifica che siamo stati chiamati dal proxy e da nessun altro. Altrimenti, revert
.
1 _;2 }Copia
Se è così, esegui la funzione che modifichiamo.
1 /* Functions that allow the proxy to actually proxy for accounts */23 function transferProxy(address from, address to, uint256 amount)4 public virtual onlyProxy() returns (bool)5 {6 _transfer(from, to, amount);7 return true;8 }910 function approveProxy(address from, address spender, uint256 amount)11 public virtual onlyProxy() returns (bool)12 {13 _approve(from, spender, amount);14 return true;15 }1617 function transferFromProxy(18 address spender,19 address from,20 address to,21 uint256 amount22 ) public virtual onlyProxy() returns (bool)23 {24 _spendAllowance(from, spender, amount);25 _transfer(from, to, amount);26 return true;27 }Mostra tuttoCopia
Queste sono tre operazioni che normalmente richiedono che il messaggio provenga direttamente dall'entità che sta trasferendo token o approvando un'indennità. Qui abbiamo una versione del proxy di queste operazioni che:
- È modificata da
onlyProxy()
, così che nessun altro possa controllarla. - Ottiene l'indirizzo che sarebbe normalmente
msg.sender
come un parametro aggiuntivo.
CalldataInterpreter.sol
L'interprete dei dati della chiamata è praticamente identico a quello precedente, tranne che le funzioni in proxy ricevono un parametro msg.sender
e non è necessaria un'indennità per transfer
.
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 }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 tuttoCopia
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)Copia
Dobbiamo dire al contratto ERC-20 di quale proxy fidarsi
1console.log("CalldataInterpreter addr:", cdi.address)23// Need two signers to verify allowances4const signers = await ethers.getSigners()5const signer = signers[0]6const poorSigner = signers[1]Copia
Per verificare approve()
e transferFrom()
, ci serve un secondo firmatario. Lo chiamiamo poorSigner
perché non riceve nessuno dei nostri token (deve avere degli ETH, ovviamente).
1// Transfer tokens2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"3const transferTx = {4 to: cdi.address,5 data: "0x02" + destAddr.slice(2, 42) + "0100",6}7await (await signer.sendTransaction(transferTx)).wait()Copia
Poiché il contratto ERC-20 si fida del proxy (cdi
), non ci serve un'indennità per inoltrare i trasferimenti.
1// approval and 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// Check the approve / transferFrom combo was done correctly17expect(await token.balanceOf(destAddr2)).to.equal(255)Mostra tuttoCopia
Testa le due nuove funzioni. Nota che transferFromTx
richiede due parametri dell'indirizzo: l'autore dell'indennità e il destinatario.
Esempio
Se desideri vedere questi file in azione senza eseguirli tu stesso, segui questi link:
- Distribuzione di
OrisUselessToken-2
(opens in a new tab) all'indirizzo0xb47c1f550d8af70b339970c673bbdb2594011696
(opens in a new tab). - Distribuzione di
CalldataInterpreter
(opens in a new tab) all'indirizzo0x0dccfd03e3aaba2f8c4ea4008487fd0380815892
(opens in a new tab). - Chiamata a
setProxy()
(opens in a new tab). - Chiamata a
faucet()
(opens in a new tab). - Chiamata a
transferProxy()
(opens in a new tab). - Chiamata a
approveProxy()
(opens in a new tab). - Chiamata a
transferFromProxy()
(opens in a new tab). Nota che questa chiamata proviene da un indirizzo diverso dagli altri,poorSigner
invece disigner
.
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 al L1 e dunque per ridurre il costo delle transazioni. Tuttavia, come fornitori di infrastruttura alla ricerca di soluzioni generiche, le nostre capacità sono limitate. Come sviluppatore di dapp, hai conoscenze specifiche per l'applicazione che ti consentono 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.
Ultima modifica: @Shiva-Sai-ssb(opens in a new tab), 30 giugno 2024