Přeskočit na hlavní obsah

Zkrácené ABI pro optimalizaci calldata

vrstva 2
Středně pokročilý
Ori Pomerantz
1. dubna 2022
13 minuta čtení

Úvod

V tomto článku se dozvíte o optimistických rollupech, nákladech na transakce na nich a o tom, jak tato odlišná struktura nákladů vyžaduje, abychom optimalizovali jiné věci než na hlavní síti Etherea. Také se dozvíte, jak tuto optimalizaci implementovat.

Úplné zveřejnění

Jsem zaměstnancem společnosti Optimism (opens in a new tab) na plný úvazek, takže příklady v tomto článku poběží na Optimismu. Zde vysvětlená technika by však měla fungovat stejně dobře i pro ostatní rollupy.

Terminologie

Při diskusi o rollupech se termín „vrstva 1“ (L1) používá pro hlavní síť (Mainnet), produkční síť Etherea. Termín „vrstva 2“ (L2) se používá pro rollup nebo jakýkoli jiný systém, který se spoléhá na L1 kvůli bezpečnosti, ale většinu zpracování provádí mimo řetězec (offchain).

Jak můžeme dále snížit náklady na transakce L2?

Optimistické rollupy musí uchovávat záznam o každé historické transakci, aby si je kdokoli mohl projít a ověřit, že aktuální stav je správný. Nejlevnější způsob, jak dostat data do hlavní sítě Etherea, je zapsat je jako calldata. Toto řešení si zvolily jak Optimism (opens in a new tab), tak Arbitrum (opens in a new tab).

Náklady na transakce L2

Náklady na transakce L2 se skládají ze dvou složek:

  1. Zpracování na L2, které je obvykle extrémně levné
  2. Úložiště na L1, které je vázáno na náklady na palivo na hlavní síti

V době, kdy toto píšu, je na Optimismu cena paliva L2 0,001 Gwei. Cena paliva L1 je naopak přibližně 40 gwei. Aktuální ceny si můžete prohlédnout zde (opens in a new tab).

Jeden bajt calldata stojí buď 4 jednotky paliva (pokud je nulový), nebo 16 jednotek paliva (pokud má jakoukoli jinou hodnotu). Jednou z nejdražších operací na EVM je zápis do úložiště. Maximální cena zápisu 32bajtového slova do úložiště na L2 je 22 100 jednotek paliva. V současnosti je to 22,1 gwei. Takže pokud ušetříme jediný nulový bajt calldata, budeme moci zapsat do úložiště asi 200 bajtů a stále na tom vyděláme.

ABI

Většina transakcí putuje do kontraktu z externě vlastněného účtu. Většina kontraktů je napsána v jazyce Solidity a interpretuje své datové pole podle aplikačního binárního rozhraní (ABI) (opens in a new tab).

ABI však bylo navrženo pro L1, kde bajt calldata stojí přibližně stejně jako čtyři aritmetické operace, nikoliv pro L2, kde bajt calldata stojí více než tisíc aritmetických operací. Calldata jsou rozdělena takto:

SekceDélkaBajtyZbytečné bajtyZbytečné palivoNezbytné bajtyNezbytné palivo
Selektor funkce40-3348116
Nuly124-15124800
Cílová adresa2016-350020320
Částka3236-67176415240
Celkem68160576

Vysvětlení:

  • Selektor funkce: Kontrakt má méně než 256 funkcí, takže je můžeme rozlišit jediným bajtem. Tyto bajty jsou obvykle nenulové, a proto stojí šestnáct jednotek paliva (opens in a new tab).
  • Nuly: Tyto bajty jsou vždy nulové, protože dvacetibajtová adresa nevyžaduje k uložení třicetidvoubajtové slovo. Bajty, které obsahují nulu, stojí čtyři jednotky paliva (viz Yellow paper (opens in a new tab), Dodatek G, str. 27, hodnota pro Gtxdatazero).
  • Částka: Pokud předpokládáme, že v tomto kontraktu je decimals osmnáct (normální hodnota) a maximální množství tokenů, které převedeme, bude 1018, dostaneme maximální částku 1036. 25615 > 1036, takže patnáct bajtů stačí.

Ztráta 160 jednotek paliva na L1 je obvykle zanedbatelná. Transakce stojí nejméně 21 000 jednotek paliva (opens in a new tab), takže na 0,8 % navíc nezáleží. Na L2 je to však jinak. Téměř celé náklady na transakci tvoří její zápis na L1. Kromě calldata transakce existuje 109 bajtů záhlaví transakce (cílová adresa, podpis atd.). Celkové náklady jsou tedy 109*16+576+160=2480 a my z toho plýtváme asi 6,5 %.

Snížení nákladů, když nemáte kontrolu nad cílem

Za předpokladu, že nemáte kontrolu nad cílovým kontraktem, můžete stále použít řešení podobné tomuto (opens in a new tab). Pojďme si projít příslušné soubory.

Token.sol

Toto je cílový kontrakt (opens in a new tab). Jedná se o standardní kontrakt ERC-20 s jednou další funkcí. Tato funkce faucet umožňuje každému uživateli získat tokeny k použití. U produkčního kontraktu ERC-20 by byla k ničemu, ale usnadňuje život, když kontrakt ERC-20 existuje pouze pro usnadnění testování.

1 /**
2 * @dev Dává volajícímu 1000 tokenů na hraní
3 */
4 function faucet() external {
5 _mint(msg.sender, 1000);
6 } // function faucet

CalldataInterpreter.sol

Toto je kontrakt, který mají transakce volat s kratšími calldata (opens in a new tab). Pojďme si ho projít řádek po řádku.

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

Potřebujeme funkci tokenu, abychom věděli, jak ji volat.

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;

Adresa tokenu, pro který jsme proxy.

1
2 /**
3 * @dev Určete adresu tokenu
4 * @param tokenAddr_ Adresa kontraktu ERC-20
5 */
6 constructor(
7 address tokenAddr_
8 ) {
9 token = OrisUselessToken(tokenAddr_);
10 } // constructor
Zobrazit vše

Adresa tokenu je jediný parametr, který musíme zadat.

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

Přečtěte hodnotu z 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");

Chystáme se načíst jedno 32bajtové (256bitové) slovo do paměti a odstranit bajty, které nejsou součástí požadovaného pole. Tento algoritmus nefunguje pro hodnoty delší než 32 bajtů a samozřejmě nemůžeme číst za koncem calldata. Na L1 může být nutné tyto testy přeskočit, abychom ušetřili palivo, ale na L2 je palivo extrémně levné, což umožňuje jakékoli kontroly správnosti, na které si vzpomeneme.

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

Mohli jsme zkopírovat data z volání do fallback() (viz níže), ale je jednodušší použít Yul (opens in a new tab), assembler EVM.

Zde používáme operační kód CALLDATALOAD (opens in a new tab) ke čtení bajtů startBytestartByte+31 do zásobníku. Obecně je syntaxe operačního kódu v Yul <název operačního kódu>(<první hodnota zásobníku, pokud existuje>, <druhá hodnota zásobníku, pokud existuje>...).

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

Součástí pole jsou pouze nejvýznamnější bajty length, takže provedeme posun doprava (opens in a new tab), abychom se zbavili ostatních hodnot. To má další výhodu v tom, že se hodnota přesune napravo od pole, takže se jedná o hodnotu samotnou, nikoli o hodnotu vynásobenou 256něčím.

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

Když volání kontraktu v Solidity neodpovídá žádnému z podpisů funkcí, zavolá se funkce fallback() (opens in a new tab) (pokud existuje). V případě CalldataInterpreter se sem dostane jakékoli volání, protože neexistují žádné jiné externí nebo veřejné funkce.

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

Přečtěte první bajt calldata, který nám sdělí funkci. Existují dva důvody, proč by zde funkce nebyla dostupná:

  1. Funkce, které jsou pure nebo view, nemění stav a nestojí palivo (při volání mimo řetězec). Nemá smysl se snažit snižovat jejich náklady na palivo.
  2. Funkce, které se spoléhají na msg.sender (opens in a new tab). Hodnota msg.sender bude adresa kontraktu CalldataInterpreter, nikoli volajícího.

Bohužel při pohledu na specifikace ERC-20 (opens in a new tab), zbývá pouze jedna funkce, transfer. To nám ponechává pouze dvě funkce: transfer (protože můžeme volat transferFrom) a faucet (protože můžeme převést tokeny zpět tomu, kdo nás volal).

1
2 // Volání metod tokenu měnících stav pomocí
3 // informací z calldata
4
5 // faucet
6 if (_func == 1) {

Volání funkce faucet(), která nemá žádné parametry.

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

Po zavolání token.faucet() získáme tokeny. Jako proxy kontrakt však tokeny nepotřebujeme. Potřebuje je EOA (externě vlastněný účet) nebo kontrakt, který nás volal. Takže převedeme všechny naše tokeny tomu, kdo nás volal.

1 // transfer (předpokládejme, že na něj máme povolenku)
2 if (_func == 2) {

Převod tokenů vyžaduje dva parametry: cílovou adresu a částku.

1 token.transferFrom(
2 msg.sender,

Volajícím povolujeme převádět pouze tokeny, které vlastní

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

Cílová adresa začíná na bajtu č. 1 (bajt č. 0 je funkce). Jako adresa je dlouhá 20 bajtů.

1 calldataVal(21, 2)

U tohoto konkrétního kontraktu předpokládáme, že maximální počet tokenů, které by kdokoli chtěl převést, se vejde do dvou bajtů (méně než 65 536).

1 );
2 }

Celkově převod zabere 35 bajtů calldata:

SekceDélkaBajty
Selektor funkce10
Cílová adresa321-32
Částka233-34
1 } // fallback
2
3} // contract CalldataInterpreter

test.js

Tento jednotkový test v JavaScriptu (opens in a new tab) nám ukazuje, jak tento mechanismus používat (a jak ověřit, že funguje správně). Budu předpokládat, že rozumíte chai (opens in a new tab) a ethers (opens in a new tab) a vysvětlím pouze části, které se týkají konkrétně tohoto kontraktu.

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()
Zobrazit vše

Začneme nasazením obou kontraktů.

1 // Získat tokeny na hraní
2 const faucetTx = {

Nemůžeme k vytváření transakcí použít funkce na vysoké úrovni, které bychom normálně používali (například token.faucet()), protože nedodržujeme ABI. Místo toho musíme transakci sestavit sami a poté ji odeslat.

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

Pro transakci musíme zadat dva parametry:

  1. to, cílová adresa. Toto je kontrakt interpretu calldata.
  2. data, calldata k odeslání. V případě volání faucet jsou data tvořena jediným bajtem, 0x01.
1
2 }
3 await (await signer.sendTransaction(faucetTx)).wait()

Voláme metodu sendTransaction podepisujícího (opens in a new tab), protože jsme již zadali cíl (faucetTx.to) a potřebujeme, aby byla transakce podepsána.

1// Zkontrolujte, zda faucet poskytuje tokeny správně
2expect(await token.balanceOf(signer.address)).to.equal(1000)

Zde ověříme zůstatek. Není třeba šetřit palivo na funkcích view, takže je prostě spouštíme normálně.

1// Dejte CDI povolenku (schválení nelze provést přes proxy)
2const approveTX = await token.approve(cdi.address, 10000)
3await approveTX.wait()
4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

Dejte interpretu calldata povolenku, aby mohl provádět převody.

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

Vytvořte transakci převodu. První bajt je „0x02“, následuje cílová adresa a nakonec částka (0x0100, což je 256 v desítkové soustavě).

1 await (await signer.sendTransaction(transferTx)).wait()
2
3 // Zkontrolujte, že máme o 256 tokenů méně
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // A že je náš cíl dostal
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe
Zobrazit vše

Snížení nákladů, když máte kontrolu nad cílovým kontraktem

Pokud máte kontrolu nad cílovým kontraktem, můžete vytvořit funkce, které obcházejí kontroly msg.sender, protože důvěřují interpretu calldata. Příklad, jak to funguje, si můžete prohlédnout zde, ve větvi control-contract (opens in a new tab).

Pokud by kontrakt reagoval pouze na externí transakce, vystačili bychom si s jedním kontraktem. To by však narušilo složitelnost. Je mnohem lepší mít kontrakt, který reaguje na běžná volání ERC-20, a další kontrakt, který reaguje na transakce s krátkými calldata.

Token.sol

V tomto příkladu můžeme upravit Token.sol. To nám umožňuje mít řadu funkcí, které může volat pouze proxy. Zde jsou nové části:

1 // Jediná adresa, která může zadat adresu CalldataInterpreter
2 address owner;
3
4 // Adresa CalldataInterpreter
5 address proxy = address(0);

Kontrakt ERC-20 musí znát identitu autorizovaného proxy. Tuto proměnnou však nemůžeme nastavit v konstruktoru, protože její hodnotu ještě neznáme. Tento kontrakt je instanciován jako první, protože proxy očekává adresu tokenu ve svém konstruktoru.

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

Adresa tvůrce (nazývaná owner) je zde uložena, protože je to jediná adresa, která smí nastavit proxy.

1 /**
2 * @dev nastaví adresu pro proxy (CalldataInterpreter).
3 * Může být zavolána pouze jednou vlastníkem
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
Zobrazit vše

Proxy má privilegovaný přístup, protože může obcházet bezpečnostní kontroly. Abychom se ujistili, že můžeme proxy důvěřovat, necháme tuto funkci volat pouze vlastníka, a to pouze jednou. Jakmile má proxy skutečnou hodnotu (nenulovou), nelze tuto hodnotu změnit, takže i kdyby se vlastník rozhodl stát se nepoctivým, nebo by byla odhalena jeho mnemotechnická pomůcka, jsme stále v bezpečí.

1 /**
2 * @dev Některé funkce mohou být volány pouze proxy.
3 */
4 modifier onlyProxy {

Toto je modifikátorová funkce (opens in a new tab), která upravuje způsob fungování ostatních funkcí.

1 require(msg.sender == proxy);

Nejprve ověřte, že nás volal proxy a nikdo jiný. Pokud ne, revert.

1 _;
2 }

Pokud ano, spusťte funkci, kterou upravujeme.

1 /* Funkce, které umožňují proxy skutečně zastupovat účty */
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 }
Zobrazit vše

Jedná se o tři operace, které obvykle vyžadují, aby zpráva přišla přímo od subjektu, který převádí tokeny nebo schvaluje povolenku. Zde máme proxy verzi těchto operací, která:

  1. Je upravena onlyProxy(), takže je nikdo jiný nesmí ovládat.
  2. Získá adresu, která by normálně byla msg.sender jako další parametr.

CalldataInterpreter.sol

Interpret calldata je téměř totožný s výše uvedeným, s výjimkou toho, že funkce proxy dostávají parametr msg.sender a není potřeba povolenka pro transfer.

1 // transfer (není potřeba povolenka)
2 if (_func == 2) {
3 token.transferProxy(
4 msg.sender,
5 address(uint160(calldataVal(1, 20))),
6 calldataVal(21, 2)
7 );
8 }
9
10 // schválit
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 }
Zobrazit vše

Test.js

Mezi předchozím testovacím kódem a tímto je několik změn.

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

Musíme kontraktu ERC-20 sdělit, kterému proxy má důvěřovat.

1console.log("CalldataInterpreter addr:", cdi.address)
2
3// K ověření povolenek potřebujeme dva podepisující
4const signers = await ethers.getSigners()
5const signer = signers[0]
6const poorSigner = signers[1]

Ke kontrole approve() a transferFrom() potřebujeme druhého podepisujícího. Říkáme mu poorSigner (chudý podepisující), protože nedostane žádný z našich tokenů (samozřejmě ale musí mít ETH).

1// Převod 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()

Protože kontrakt ERC-20 důvěřuje proxy (cdi), nepotřebujeme povolenku k předávání převodů.

1// schválení a 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// Zkontrolujte, zda byla kombinace approve / transferFrom provedena správně
17expect(await token.balanceOf(destAddr2)).to.equal(255)
Zobrazit vše

Otestujte dvě nové funkce. Všimněte si, že transferFromTx vyžaduje dva parametry adresy: dárce povolenky a příjemce.

Závěr

Jak Optimism (opens in a new tab), tak Arbitrum (opens in a new tab) hledají způsoby, jak zmenšit velikost calldata zapisovaných na L1, a tím i náklady na transakce. Jako poskytovatelé infrastruktury, kteří hledají obecná řešení, jsou však naše schopnosti omezené. Jako vývojář dapp máte znalosti specifické pro danou aplikaci, což vám umožňuje optimalizovat calldata mnohem lépe, než bychom mohli v obecném řešení. Doufejme, že vám tento článek pomůže najít ideální řešení pro vaše potřeby.

Více z mé práce najdete zde (opens in a new tab).

Stránka naposledy aktualizována: 22. srpna 2025

Byl tento tutoriál užitečný?