Vai al contenuto principale

ABI brevi per l'ottimizzazione dei calldata

livello 2
Intermedio
Ori Pomerantz
1 aprile 2022
15 minuti letti minute read

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:

  1. Elaborazione su L2, solitamente estremamente economica
  2. 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:

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

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 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 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 with
3 */
4 function faucet() external {
5 _mint(msg.sender, 1000);
6 } // function faucet
Copia

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: Unlicense
2pragma solidity ^0.8.0;
3
4
5import { OrisUselessToken } from "./Token.sol";
Copia

Ci serve la funzione del token per sapere come chiamarla.

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;
Copia

L'indirizzo del token per cui siamo un proxy.

1
2 /**
3 * @dev Specify the token address
4 * @param tokenAddr_ ERC-20 contract address
5 */
6 constructor(
7 address tokenAddr_
8 ) {
9 token = OrisUselessToken(tokenAddr_);
10 } // constructor
Mostra tutto
Copia

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

1
2 _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.

1
2 return _retVal;
3 }
4
5
6 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;
2
3 _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:

  1. Le funzioni che sono pure o view 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.
  2. Le funzioni che si affidano a msg.sender(opens in a new tab). Il valore del msg.sender sarà l'indirizzo CalldataInterpreter, 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).

1
2 // Call the state changing methods of token using
3 // information from the calldata
4
5 // faucet
6 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:

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

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

Iniziamo distribuendo entrambi i contratti.

1 // Get tokens to play with
2 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:

  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 faucet, i dati sono in 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 ci serve che la transazione sia firmata.

1// Check the faucet provides the tokens correctly
2expect(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 tokens
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, ovvero 256 in decimale).

1 await (await signer.sendTransaction(transferTx)).wait()
2
3 // Check that we have 256 tokens less
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // And that our destination got them
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe
Mostra tutto

Esempio

Se desideri vedere questi file in azione senza eseguirli tu stesso, segui questi link:

  1. Distribuzione diOrisUselessToken(opens in a new tab) all'indirizzo 0x950c753c0edbde44a74d3793db738a318e9c8ce8(opens in a new tab).
  2. Distribuzione di CalldataInterpreter(opens in a new tab) all'indirizzo 0x16617fea670aefe3b9051096c0eb4aeb4b3a5f55(opens in a new tab).
  3. Chiamata a faucet()(opens in a new tab).
  4. Chiamata a OrisUselessToken.approve()(opens in a new tab). Questa chiamata deve andare direttamente al contratto del token, poiché l'elaborazione si affida al msg.sender.
  5. 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 address
2 address owner;
3
4 // The CalldataInterpreter address
5 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 owner
4 */
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");
8
9 proxy = _proxy;
10 } // function setProxy
Mostra tutto
Copia

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 */
2
3 function transferProxy(address from, address to, uint256 amount)
4 public virtual onlyProxy() returns (bool)
5 {
6 _transfer(from, to, amount);
7 return true;
8 }
9
10 function approveProxy(address from, address spender, uint256 amount)
11 public virtual onlyProxy() returns (bool)
12 {
13 _approve(from, spender, amount);
14 return true;
15 }
16
17 function transferFromProxy(
18 address spender,
19 address from,
20 address to,
21 uint256 amount
22 ) public virtual onlyProxy() returns (bool)
23 {
24 _spendAllowance(from, spender, amount);
25 _transfer(from, to, amount);
26 return true;
27 }
Mostra tutto
Copia

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:

  1. È modificata da onlyProxy(), così che nessun altro possa controllarla.
  2. 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 }
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
Copia

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)
2
3// Need two signers to verify allowances
4const 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 tokens
2const 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 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// Check the approve / transferFrom combo was done correctly
17expect(await token.balanceOf(destAddr2)).to.equal(255)
Mostra tutto
Copia

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:

  1. Distribuzione di OrisUselessToken-2(opens in a new tab) all'indirizzo 0xb47c1f550d8af70b339970c673bbdb2594011696(opens in a new tab).
  2. Distribuzione di CalldataInterpreter(opens in a new tab) all'indirizzo 0x0dccfd03e3aaba2f8c4ea4008487fd0380815892(opens in a new tab).
  3. Chiamata a setProxy()(opens in a new tab).
  4. Chiamata a faucet()(opens in a new tab).
  5. Chiamata a transferProxy()(opens in a new tab).
  6. Chiamata a approveProxy()(opens in a new tab).
  7. Chiamata a transferFromProxy()(opens in a new tab). Nota che questa chiamata proviene da un indirizzo diverso dagli altri, poorSigner invece di signer.

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: @nhsz(opens in a new tab), 7 maggio 2024

Questo tutorial è stato utile?