Zkrácené ABI pro optimalizaci calldata
Ú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:
- Zpracování na L2, které je obvykle extrémně levné
- Ú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:
| Sekce | Délka | Bajty | Zbytečné bajty | Zbytečné palivo | Nezbytné bajty | Nezbytné palivo |
|---|---|---|---|---|---|---|
| Selektor funkce | 4 | 0-3 | 3 | 48 | 1 | 16 |
| Nuly | 12 | 4-15 | 12 | 48 | 0 | 0 |
| Cílová adresa | 20 | 16-35 | 0 | 0 | 20 | 320 |
| Částka | 32 | 36-67 | 17 | 64 | 15 | 240 |
| Celkem | 68 | 160 | 576 |
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
decimalsosmná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 faucetCalldataInterpreter.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: Unlicense2pragma solidity ^0.8.0;345import { OrisUselessToken } from "./Token.sol";Potřebujeme funkci tokenu, abychom věděli, jak ji volat.
1contract CalldataInterpreter {23 OrisUselessToken public immutable token;Adresa tokenu, pro který jsme proxy.
12 /**3 * @dev Určete adresu tokenu4 * @param tokenAddr_ Adresa kontraktu ERC-205 */6 constructor(7 address tokenAddr_8 ) {9 token = OrisUselessToken(tokenAddr_);10 } // constructorZobrazit všeAdresa 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;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");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ů startByte až startByte+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>...).
12 _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.
12 return _retVal;3 }456 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;23 _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á:
- Funkce, které jsou
pureneboview, 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. - Funkce, které se spoléhají na
msg.sender(opens in a new tab). Hodnotamsg.senderbude adresa kontraktuCalldataInterpreter, 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).
12 // Volání metod tokenu měnících stav pomocí3 // informací z calldata45 // faucet6 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:
| Sekce | Délka | Bajty |
|---|---|---|
| Selektor funkce | 1 | 0 |
| Cílová adresa | 32 | 1-32 |
| Částka | 2 | 33-34 |
1 } // fallback23} // contract CalldataInterpretertest.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");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()Zobrazit všeZač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:
to, cílová adresa. Toto je kontrakt interpretu calldata.data, calldata k odeslání. V případě volání faucet jsou data tvořena jediným bajtem,0x01.
12 }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()23 // Zkontrolujte, že máme o 256 tokenů méně4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)56 // A že je náš cíl dostal7 expect (await token.balanceOf(destAddr)).to.equal(256)8 }) // it9}) // describeZobrazit všeSníž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 CalldataInterpreter2 address owner;34 // Adresa CalldataInterpreter5 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íkem4 */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 setProxyZobrazit všeProxy 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 */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 }Zobrazit všeJedná 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á:
- Je upravena
onlyProxy(), takže je nikdo jiný nesmí ovládat. - Získá adresu, která by normálně byla
msg.senderjako 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 }910 // schválit11 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 }Zobrazit všeTest.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)23// 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 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// Zkontrolujte, zda byla kombinace approve / transferFrom provedena správně17expect(await token.balanceOf(destAddr2)).to.equal(255)Zobrazit všeOtestujte 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