Przejdź do głównej zawartości

Krótkie ABI w celu optymalizacji Calldata

warstwa 2
Średnio zaawansowany
Ori Pomerantz
1 kwietnia 2022
13 minuta czytania

Wprowadzenie

W tym artykule dowiesz się o rollupach optymistycznych, kosztach transakcji na nich oraz o tym, jak odmienna struktura kosztów wymaga od nas optymalizacji pod kątem innych rzeczy niż w sieci głównej Ethereum. Dowiesz się również, jak wdrożyć tę optymalizację.

Pełne ujawnienie

Jestem pracownikiem zatrudnionym na pełen etat w Optimism (opens in a new tab), więc przykłady w tym artykule będą działać na Optimism. Jednakże wyjaśniona tutaj technika powinna działać równie dobrze w przypadku innych rollupów.

Terminologia

W dyskusjach o rollupach termin „warstwa 1” (L1) jest używany w odniesieniu do sieci głównej (Mainnet), produkcyjnej sieci Ethereum. Termin „warstwa 2” (L2) jest używany w odniesieniu do rollupu lub jakiegokolwiek innego systemu, który opiera się na L1 w kwestii bezpieczeństwa, ale wykonuje większość przetwarzania poza łańcuchem.

Jak możemy jeszcze bardziej obniżyć koszt transakcji L2?

Rollupy optymistyczne muszą przechowywać zapis każdej historycznej transakcji, aby każdy mógł je przejrzeć i zweryfikować, czy obecny stan jest prawidłowy. Najtańszym sposobem na wprowadzenie danych do sieci głównej Ethereum jest zapisanie ich jako calldata. To rozwiązanie zostało wybrane zarówno przez Optimism (opens in a new tab), jak i Arbitrum (opens in a new tab).

Koszt transakcji L2

Koszt transakcji L2 składa się z dwóch składników:

  1. Przetwarzanie na L2, które jest zazwyczaj niezwykle tanie
  2. Przechowywanie na L1, które jest powiązane z kosztami gazu w sieci głównej

W chwili, gdy to piszę, na Optimism koszt gazu L2 wynosi 0,001 Gwei. Z drugiej strony, koszt gazu L1 wynosi około 40 gwei. Aktualne ceny można zobaczyć tutaj (opens in a new tab).

Jeden bajt calldata kosztuje albo 4 gazu (jeśli jest to zero), albo 16 gazu (jeśli ma inną wartość). Jedną z najdroższych operacji w EVM jest zapis do pamięci masowej. Maksymalny koszt zapisu 32-bajtowego słowa do pamięci masowej na L2 wynosi 22100 gazu. Obecnie jest to 22,1 gwei. Jeśli więc uda nam się zaoszczędzić jeden zerowy bajt calldata, będziemy w stanie zapisać około 200 bajtów do pamięci masowej i nadal wyjdziemy na tym na plus.

ABI

Zdecydowana większość transakcji uzyskuje dostęp do kontraktu z konta zewnętrznego. Większość kontraktów jest napisana w Solidity i interpretuje swoje pole danych zgodnie z binarnym interfejsem aplikacji (ABI) (opens in a new tab).

Jednak ABI zostało zaprojektowane dla L1, gdzie bajt calldata kosztuje mniej więcej tyle samo co cztery operacje arytmetyczne, a nie dla L2, gdzie bajt calldata kosztuje ponad tysiąc operacji arytmetycznych. Calldata jest podzielona w następujący sposób:

SekcjaDługośćBajtyZmarnowane bajtyZmarnowany gazNiezbędne bajtyNiezbędny gaz
Selektor funkcji40-3348116
Zera124-15124800
Adres docelowy2016-350020320
Kwota3236-67176415240
Łącznie68160576

Wyjaśnienie:

  • Selektor funkcji: Kontrakt ma mniej niż 256 funkcji, więc możemy je rozróżnić za pomocą jednego bajtu. Te bajty są zazwyczaj niezerowe i dlatego kosztują szesnaście gazu (opens in a new tab).
  • Zera: Te bajty są zawsze zerowe, ponieważ dwudziestobajtowy adres nie wymaga trzydziestodwubajtowego słowa, aby go pomieścić. Bajty, które zawierają zero, kosztują cztery gazu (zobacz yellow paper (opens in a new tab), Dodatek G, s. 27, wartość dla Gtxdatazero).
  • Ilość: Jeśli założymy, że w tym kontrakcie decimals wynosi osiemnaście (normalna wartość), a maksymalna ilość tokenów, które transferujemy, wyniesie 1018, otrzymamy maksymalną ilość 1036. 25615 > 1036, więc piętnaście bajtów wystarczy.

Strata 160 gazu na L1 jest normalnie znikoma. Transakcja kosztuje co najmniej 21 000 gazu (opens in a new tab), więc dodatkowe 0,8% nie ma znaczenia. Jednak na L2 sprawy mają się inaczej. Prawie cały koszt transakcji to zapisanie jej na L1. Oprócz calldata transakcji, istnieje 109 bajtów nagłówka transakcji (adres docelowy, podpis itp.). Całkowity koszt wynosi zatem 109*16+576+160=2480, a my marnujemy około 6,5% tej kwoty.

Redukcja kosztów, gdy nie kontrolujesz kontraktu docelowego

Zakładając, że nie masz kontroli nad kontraktem docelowym, nadal możesz użyć rozwiązania podobnego do tego (opens in a new tab). Przejdźmy do odpowiednich plików.

Token.sol

To jest kontrakt docelowy (opens in a new tab). Jest to standardowy kontrakt ERC-20, z jedną dodatkową funkcją. Ta funkcja faucet pozwala każdemu użytkownikowi otrzymać trochę tokenów do wykorzystania. Uczyniłoby to produkcyjny kontrakt ERC-20 bezużytecznym, ale ułatwia życie, gdy ERC-20 istnieje tylko w celu ułatwienia testowania.

1 /**
2 * @dev Daje wywołującemu 1000 tokenów do zabawy
3 */
4 function faucet() external {
5 _mint(msg.sender, 1000);
6 } // function faucet

CalldataInterpreter.sol

To jest kontrakt, który transakcje mają wywoływać z krótszymi calldata (opens in a new tab). Przejdźmy przez niego linia po linii.

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

Potrzebujemy interfejsu kontraktu tokena, aby wiedzieć, jak go wywoływać.

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;

Adres tokena, dla którego jesteśmy proxy.

1
2 /**
3 * @dev Określ adres tokena
4 * @param tokenAddr_ adres kontraktu ERC-20
5 */
6 constructor(
7 address tokenAddr_
8 ) {
9 token = OrisUselessToken(tokenAddr_);
10 } // constructor
Pokaż wszystko

Adres tokena jest jedynym parametrem, który musimy określić.

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

Odczytaj wartość z calldata.

1 uint _retVal;
2
3 require(length < 0x21,
4 "calldataVal limit długości to 32 bajty");
5
6 require(length + startByte <= msg.data.length,
7 "calldataVal próbuje czytać poza calldatasize");

Zamierzamy załadować do pamięci pojedyncze 32-bajtowe (256-bitowe) słowo i usunąć bajty, które nie są częścią pola, które nas interesuje. Ten algorytm nie działa dla wartości dłuższych niż 32 bajty i oczywiście nie możemy czytać poza końcem calldata. Na L1 może być konieczne pominięcie tych testów w celu zaoszczędzenia na gazie, ale na L2 gaz jest niezwykle tani, co umożliwia wszelkie testy poprawności, jakie możemy sobie wyobrazić.

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

Moglibyśmy skopiować dane z wywołania do fallback() (patrz niżej), ale łatwiej jest użyć Yul (opens in a new tab), języka asemblera EVM.

Tutaj używamy opcodu CALLDATALOAD (opens in a new tab), aby odczytać bajty od startByte do startByte+31 na stos. Ogólnie rzecz biorąc, składnia opcodu w Yul to <nazwa opcodu>(<pierwsza wartość stosu, jeśli istnieje>, <druga wartość stosu, jeśli istnieje>...).

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

Tylko najbardziej znaczące bajty o długości length są częścią pola, więc wykonujemy przesunięcie w prawo (opens in a new tab), aby pozbyć się pozostałych wartości. Ma to dodatkową zaletę, że przenosi wartość na prawo od pola, więc jest to sama wartość, a nie wartość pomnożona przez 256coś.

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

Gdy wywołanie do kontraktu Solidity nie pasuje do żadnej z sygnatur funkcji, wywołuje funkcję fallback() (opens in a new tab) (zakładając, że taka istnieje). W przypadku CalldataInterpreter każde wywołanie trafia tutaj, ponieważ nie ma innych funkcji external ani public.

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

Odczytaj pierwszy bajt calldata, który informuje nas o funkcji. Istnieją dwa powody, dla których funkcja nie byłaby tutaj dostępna:

  1. Funkcje, które są pure lub view, nie zmieniają stanu i nie kosztują gazu (gdy są wywoływane poza łańcuchem). Nie ma sensu próbować zmniejszać ich kosztu gazu.
  2. Funkcje, które opierają się na msg.sender (opens in a new tab). Wartością msg.sender będzie adres CalldataInterpreter, a nie adres wywołującego.

Niestety, patrząc na specyfikacje ERC-20 (opens in a new tab), pozostaje tylko jedna funkcja, transfer. Pozostawia to nam tylko dwie funkcje: transfer (ponieważ możemy wywołać transferFrom) i faucet (ponieważ możemy przelać tokeny z powrotem do tego, kto nas wywołał).

1
2 // Wywołaj metody zmieniające stan tokena, używając
3 // informacji z calldata
4
5 // faucet
6 if (_func == 1) {

Wywołanie faucet(), które nie ma parametrów.

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

Po wywołaniu token.faucet() otrzymujemy tokeny. Jednak jako kontrakt proxy nie potrzebujemy tokenów. Potrzebuje ich EOA (konto należące do podmiotu zewnętrznego) lub kontrakt, który nas wywołał. Więc transferujemy wszystkie nasze tokeny do tego, kto nas wywołał.

1 // transfer (zakładamy, że mamy na to zgodę)
2 if (_func == 2) {

Przesyłanie tokenów wymaga dwóch parametrów: adresu docelowego i kwoty.

1 token.transferFrom(
2 msg.sender,

Zezwalamy wywołującym tylko na transfer posiadanych przez nich tokenów

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

Adres docelowy zaczyna się od bajtu nr 1 (bajt nr 0 to funkcja). Jako adres, ma długość 20 bajtów.

1 calldataVal(21, 2)

W przypadku tego konkretnego kontraktu zakładamy, że maksymalna liczba tokenów, jaką ktokolwiek chciałby przenieść, mieści się w dwóch bajtach (mniej niż 65536).

1 );
2 }

Ogólnie rzecz biorąc, transfer zajmuje 35 bajtów calldata:

SekcjaDługośćBajty
Selektor funkcji10
Adres docelowy321-32
Kwota233-34
1 } // fallback
2
3} // kontrakt CalldataInterpreter

test.js

Ten test jednostkowy JavaScript (opens in a new tab) pokazuje, jak używać tego mechanizmu (i jak zweryfikować jego prawidłowe działanie). Zakładam, że rozumiesz chai (opens in a new tab) i ethers (opens in a new tab) i wyjaśniam tylko te części, które dotyczą konkretnie kontraktu.

1const { expect } = require("chai");
2
3describe("CalldataInterpreter", function () {
4 it("Powinien pozwolić nam używać tokenów", async function () {
5 const Token = await ethers.getContractFactory("OrisUselessToken")
6 const token = await Token.deploy()
7 await token.deployed()
8 console.log("Adres tokena:", 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("Adres CalldataInterpreter:", cdi.address)
14
15 const signer = await ethers.getSigner()
Pokaż wszystko

Zaczynamy od wdrożenia obu kontraktów.

1 // Pobierz tokeny do zabawy
2 const faucetTx = {

Nie możemy używać funkcji wysokiego poziomu, których normalnie byśmy użyli (takich jak token.faucet()) do tworzenia transakcji, ponieważ nie przestrzegamy ABI. Zamiast tego musimy sami zbudować transakcję, a następnie ją wysłać.

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

Istnieją dwa parametry, które musimy podać dla transakcji:

  1. to, adres docelowy. To jest kontrakt interpretera calldata.
  2. data, calldata do wysłania. W przypadku wywołania faucet, danymi jest pojedynczy bajt, 0x01.
1
2 }
3 await (await signer.sendTransaction(faucetTx)).wait()

Wywołujemy metodę sendTransaction sygnatariusza (opens in a new tab), ponieważ już określiliśmy miejsce docelowe (faucetTx.to) i potrzebujemy, aby transakcja została podpisana.

1// Sprawdź, czy faucet poprawnie dostarcza tokeny
2expect(await token.balanceOf(signer.address)).to.equal(1000)

Tutaj weryfikujemy saldo. Nie ma potrzeby oszczędzania gazu na funkcjach view, więc po prostu uruchamiamy je normalnie.

1// Daj CDI upoważnienie (zatwierdzenia nie mogą być przekazywane przez proxy)
2const approveTX = await token.approve(cdi.address, 10000)
3await approveTX.wait()
4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

Daj interpreterowi calldata upoważnienie do wykonywania transferów.

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

Utwórz transakcję transferu. Pierwszy bajt to „0x02”, po nim następuje adres docelowy, a na końcu kwota (0x0100, co w systemie dziesiętnym daje 256).

1 await (await signer.sendTransaction(transferTx)).wait()
2
3 // Sprawdź, czy mamy o 256 tokenów mniej
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // I czy nasz cel je otrzymał
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe
Pokaż wszystko

Zmniejszenie kosztów, gdy kontrolujesz kontrakt docelowy

Jeśli masz kontrolę nad kontraktem docelowym, możesz tworzyć funkcje, które omijają sprawdzanie msg.sender, ponieważ ufają interpreterowi calldata. Przykład działania można zobaczyć tutaj, w gałęzi control-contract (opens in a new tab).

Gdyby kontrakt odpowiadał tylko na transakcje zewnętrzne, moglibyśmy sobie poradzić z jednym kontraktem. Jednakże, złamałoby to kompozycyjność. O wiele lepiej jest mieć kontrakt, który odpowiada na normalne wywołania ERC-20, i inny kontrakt, który odpowiada na transakcje z krótkimi danymi wywołania.

Token.sol

W tym przykładzie możemy zmodyfikować Token.sol. Pozwala to na posiadanie wielu funkcji, które może wywoływać tylko proxy. Oto nowe części:

1 // Jedyny adres uprawniony do określenia adresu CalldataInterpreter
2 address owner;
3
4 // Adres CalldataInterpreter
5 address proxy = address(0);

Kontrakt ERC-20 musi znać tożsamość autoryzowanego proxy. Nie możemy jednak ustawić tej zmiennej w konstruktorze, ponieważ nie znamy jeszcze jej wartości. Ten kontrakt jest tworzony jako pierwszy, ponieważ proxy oczekuje adresu tokena w swoim konstruktorze.

1 /**
2 * @dev Wywołuje konstruktor ERC20.
3 */
4 constructor(
5 ) ERC20("Oris useless token-2", "OUT-2") {
6 owner = msg.sender;
7 }

Adres twórcy (o nazwie owner) jest tutaj przechowywany, ponieważ jest to jedyny adres uprawniony do ustawienia proxy.

1 /**
2 * @dev Ustawia adres dla proxy (CalldataInterpreter).
3 * Może być wywołane tylko raz przez właściciela
4 */
5 function setProxy(address _proxy) external {
6 require(msg.sender == owner, "Może być wywołane tylko przez właściciela");
7 require(proxy == address(0), "Proxy jest już ustawione");
8
9 proxy = _proxy;
10 } // funkcja setProxy
Pokaż wszystko

Proxy ma uprzywilejowany dostęp, ponieważ może omijać kontrole bezpieczeństwa. Aby upewnić się, że możemy zaufać proxy, pozwalamy tylko owner na wywołanie tej funkcji, i to tylko raz. Gdy proxy ma rzeczywistą wartość (niezerową), wartość ta nie może ulec zmianie, więc nawet jeśli właściciel zdecyduje się zbuntować lub jego mnemonik zostanie ujawniony, nadal jesteśmy bezpieczni.

1 /**
2 * @dev Niektóre funkcje mogą być wywoływane tylko przez proxy.
3 */
4 modifier onlyProxy {

Jest to funkcja modifier (opens in a new tab), która modyfikuje działanie innych funkcji.

1 require(msg.sender == proxy);

Najpierw zweryfikuj, czy zostaliśmy wywołani przez proxy i nikogo innego. Jeśli nie, revert.

1 _;
2 }

Jeśli tak, uruchom funkcję, którą modyfikujemy.

1 /* Funkcje, które pozwalają proxy na faktyczne pośredniczenie dla kont */
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 }
Pokaż wszystko

Są to trzy operacje, które normalnie wymagają, aby wiadomość pochodziła bezpośrednio od podmiotu transferującego tokeny lub zatwierdzającego upoważnienie. Tutaj mamy wersję proxy tych operacji, która:

  1. Jest modyfikowana przez onlyProxy(), więc nikt inny nie może ich kontrolować.
  2. Otrzymuje adres, który normalnie byłby msg.sender jako dodatkowy parametr.

CalldataInterpreter.sol

Interpreter calldata jest prawie identyczny z powyższym, z wyjątkiem tego, że funkcje przekazywane przez proxy otrzymują parametr msg.sender i nie ma potrzeby posiadania upoważnienia do transferu.

1 // transfer (nie ma potrzeby posiadania upoważnienia)
2 if (_func == 2) {
3 token.transferProxy(
4 msg.sender,
5 address(uint160(calldataVal(1, 20))),
6 calldataVal(21, 2)
7 );
8 }
9
10 // zatwierdzenie
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 }
Pokaż wszystko

Test.js

Jest kilka zmian między poprzednim kodem testującym a tym.

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

Musimy poinformować kontrakt ERC-20, któremu proxy ma ufać

1console.log("Adres CalldataInterpreter:", cdi.address)
2
3// Potrzebujemy dwóch sygnatariuszy do weryfikacji upoważnień
4const signers = await ethers.getSigners()
5const signer = signers[0]
6const poorSigner = signers[1]

Aby sprawdzić approve() i transferFrom(), potrzebujemy drugiego sygnatariusza. Nazywamy go poorSigner, ponieważ nie dostaje żadnych naszych tokenów (musi mieć oczywiście ETH).

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

Ponieważ kontrakt ERC-20 ufa proxy (cdi), nie potrzebujemy upoważnienia do przekazywania transferów.

1// zatwierdzenie i 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// Sprawdź, czy kombinacja zatwierdzenia / transferFrom została wykonana poprawnie
17expect(await token.balanceOf(destAddr2)).to.equal(255)
Pokaż wszystko

Przetestuj dwie nowe funkcje. Zauważ, że transferFromTx wymaga dwóch parametrów adresu: dającego upoważnienie i odbiorcy.

Wnioski

Zarówno Optimism (opens in a new tab), jak i Arbitrum (opens in a new tab) szukają sposobów na zmniejszenie rozmiaru calldata zapisywanych w L1, a tym samym kosztów transakcji. Jednak jako dostawcy infrastruktury poszukujący ogólnych rozwiązań, nasze możliwości są ograniczone. Jako deweloper dApp, masz wiedzę specyficzną dla aplikacji, co pozwala na znacznie lepszą optymalizację calldata, niż moglibyśmy to zrobić w rozwiązaniu ogólnym. Mamy nadzieję, że ten artykuł pomoże Ci znaleźć idealne rozwiązanie dla Twoich potrzeb.

Zobacz więcej mojej pracy tutaj (opens in a new tab).

Strona ostatnio zaktualizowana: 22 sierpnia 2025

Czy ten samouczek był pomocny?