Krátká ABI pro optimalizaci dat volání
Ú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 Ethereum Mainnetu. Také se naučíte, jak tuto optimalizaci implementovat.
Plné odhalení
Jsem zaměstnancem Optimism (opens in a new tab) na plný úvazek, takže příklady v tomto článku poběží na síti Optimism. Zde vysvětlená technika by však měla fungovat stejně dobře i pro jiné rollupy.
Terminologie
Při diskuzi o rollupech se termín „vrstva 1 (l1)“ používá pro Mainnet, produkční síť Ethereum. Termín „vrstva 2 (l2)“ se používá pro rollup nebo jakýkoli jiný systém, který spoléhá na l1 z hlediska bezpečnosti, ale většinu svého zpracování provádí offchain.
Jak můžeme dále snížit náklady na transakce na l2?
Optimistické rollupy musí uchovávat záznam o každé historické transakci, aby si je kdokoli mohl projít a ověřit, že je aktuální stav správný. Nejlevnější způsob, jak dostat data do Ethereum Mainnetu, je zapsat je jako data volání. Toto řešení zvolily sítě Optimism (opens in a new tab) i Arbitrum (opens in a new tab).
Náklady na transakce na l2
Náklady na transakce na 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 gas na Mainnetu
V době psaní tohoto článku je na síti Optimism cena l2 gasu 0,001 Gwei. Cena l1 gasu je naproti tomu přibližně 40 Gwei. Aktuální ceny můžete vidět zde (opens in a new tab).
Bajt dat volání stojí buď 4 gas (pokud je nulový), nebo 16 gas (pokud má jakoukoli jinou hodnotu). Jednou z nejdražších operací v 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 gas. V současnosti to je 22,1 Gwei. Takže pokud dokážeme ušetřit jediný nulový bajt dat volání, budeme moci zapsat asi 200 bajtů do úložiště a stále na tom budeme lépe.
ABI
Drtivá většina transakcí přistupuje ke 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).
Nicméně ABI bylo navrženo pro l1, kde bajt dat volání stojí přibližně stejně jako čtyři aritmetické operace, a ne pro l2, kde bajt dat volání stojí více než tisíc aritmetických operací. Data volání jsou rozdělena takto:
| Sekce | Délka | Bajty | Promarněné bajty | Promarněný gas | Nezbytné bajty | Nezbytný gas |
|---|---|---|---|---|---|---|
| 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 gas (opens in a new tab).
- Nuly: Tyto bajty jsou vždy nulové, protože dvacetibajtová adresa nevyžaduje k uložení dvaatřicetibajtové slovo.
Bajty, které obsahují nulu, stojí čtyři gas (viz yellow paper (opens in a new tab), dodatek G,
str. 27, hodnota pro
Gtxdatazero). - Částka: Pokud budeme předpokládat, že v tomto kontraktu je
decimalsosmnáct (běžná 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 gas na l1 je normálně zanedbatelná. Transakce stojí minimálně 21 000 gas (opens in a new tab), takže dalších 0,8 % nehraje roli.
Na l2 je to však jiné. Téměř celé náklady na transakci tvoří její zápis na l1.
Kromě dat volání transakce je zde 109 bajtů hlavičky transakce (cílová adresa, podpis atd.).
Celkové náklady jsou tedy 109*16+576+160=2480 a my z nich plýtváme asi 6,5 %.
Snižování nákladů, když nemáte pod kontrolou cíl
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í ERC-20 kontrakt s jednou další funkcí.
Tato funkce faucet umožňuje kterémukoli uživateli získat nějaký token k použití.
Produkční ERC-20 kontrakt by to učinilo nepoužitelným, ale usnadňuje to život, když ERC-20 existuje pouze pro usnadnění testování.
/**
* @dev Dává volajícímu 1000 tokenů na hraní
*/
function faucet() external {
_mint(msg.sender, 1000);
} // function faucet
CalldataInterpreter.sol
Toto je kontrakt, který by měly transakce volat s kratšími daty volání (opens in a new tab). Pojďme si ho projít řádek po řádku.
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
import { OrisUselessToken } from "./Token.sol";
Potřebujeme funkci tokenu, abychom věděli, jak ji zavolat.
kontrakt CalldataInterpreter {
OrisUselessToken public immutable token;
Adresa tokenu, pro který jsme proxy kontraktem.
/**
* @dev Určuje adresu tokenu
* @param tokenAddr_ adresa kontraktu ERC-20
*/
konstruktor(
address tokenAddr_
) {
token = OrisUselessToken(tokenAddr_);
} // constructor
Adresa tokenu je jediný parametr, který musíme specifikovat.
function calldataVal(uint startByte, uint length)
private pure returns (uint) {
Přečtení hodnoty z dat volání.
uint _retVal;
require(length < 0x21,
"calldataVal length limit is 32 bytes");
require(length + startByte <= msg.data.length,
"calldataVal trying to read beyond calldatasize");
Načteme do paměti jedno 32bajtové (256bitové) slovo a odstraníme 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 dat volání. Na l1 by mohlo být nutné tyto testy přeskočit, aby se ušetřil gas, ale na l2 je gas extrémně levný, což umožňuje jakékoli kontroly správnosti, na které si vzpomeneme.
assembly {
_retVal := calldataload(startByte)
}
Mohli jsme zkopírovat data z volání do fallback() (viz níže), ale je snazší použít Yul (opens in a new tab), jazyk symbolických adres (assembly) pro EVM.
Zde používáme operační kód CALLDATALOAD (opens in a new tab) k načtení bajtů startByte až startByte+31 do zásobníku.
Obecně je syntaxe operačního kódu v jazyce Yul <opcode name>(<first stack value, if any>,<second stack value, if any>...).
_retVal = _retVal >> (256-length*8);
Pouze nejvýznamnější bajty length jsou součástí pole, takže provedeme posun vpravo (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 je to samotná hodnota, a ne hodnota krát 256něco.
return _retVal;
}
fallback() external {
Když volání Solidity kontraktu neodpovídá žádnému z podpisů funkcí, zavolá funkci fallback() (opens in a new tab) (za předpokladu, že existuje).
V případě CalldataInterpreter se sem dostane jakékoli volání, protože neexistují žádné jiné funkce external nebo public.
uint _func;
_func = calldataVal(0, 1);
Přečtení prvního bajtu dat volání, který nám říká, o jakou funkci jde. Existují dva důvody, proč by zde funkce nebyla dostupná:
- Funkce, které jsou
pureneboview, nemění stav a nestojí žádný gas (když jsou volány offchain). Nemá smysl se snažit snížit jejich náklady na gas. - Funkce, které spoléhají na
msg.sender(opens in a new tab). Hodnotamsg.senderbude adresaCalldataInterpreter, nikoli volajícího.
Bohužel, při pohledu na specifikace ERC-20 (opens in a new tab) nám zbývá pouze jedna funkce, transfer.
To nám ponechává pouze dvě funkce: transfer (protože můžeme zavolat transferFrom) a faucet (protože můžeme převést tokeny zpět tomu, kdo nás zavolal).
// Volat metody tokenu měnící stav pomocí
// informací z dat volání
// faucet
if (_func == 1) {
Volání faucet(), které nemá parametry.
token.faucet();
token.transfer(msg.sender,
token.balanceOf(address(this)));
}
Po zavolání token.faucet() získáme tokeny. Nicméně jako proxy kontrakt tokeny nepotřebujeme.
EOA (externě vlastněný účet) nebo kontrakt, který nás zavolal, ano.
Takže převedeme všechny naše tokeny tomu, kdo nás zavolal.
// převod (předpokládáme, že pro něj máme povolený limit)
if (_func == 2) {
Převod tokenů vyžaduje dva parametry: cílovou adresu a částku.
token.transferFrom(
msg.sender,
Volajícím umožňujeme převádět pouze tokeny, které vlastní
address(uint160(calldataVal(1, 20))),
Cílová adresa začíná na bajtu č. 1 (bajt č. 0 je funkce). Jako adresa je dlouhá 20 bajtů.
calldataVal(21, 2)
Pro tento konkrétní kontrakt 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ž 65536).
);
}
Celkově převod zabere 35 bajtů dat volání:
| Sekce | Délka | Bajty |
|---|---|---|
| Selektor funkce | 1 | 0 |
| Cílová adresa | 32 | 1-32 |
| Částka | 2 | 33-34 |
} // fallback
} // contract CalldataInterpreter
test.js
Tento JavaScriptový jednotkový test (unit test) (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 knihovnám chai (opens in a new tab) a ethers (opens in a new tab), a vysvětlím pouze části, které se konkrétně týkají kontraktu.
const { expect } = require("chai");
describe("CalldataInterpreter", function () {
it("Should let us use tokens", async function () {
const Token = await ethers.getContractFactory("OrisUselessToken")
const token = await Token.deploy()
await token.deployed()
console.log("Token addr:", token.address)
const Cdi = await ethers.getContractFactory("CalldataInterpreter")
const cdi = await Cdi.deploy(token.address)
await cdi.deployed()
console.log("CalldataInterpreter addr:", cdi.address)
const signer = await ethers.getSigner()
Začneme nasazením obou kontraktů.
// Získat tokeny na hraní
const faucetTx = {
K vytváření transakcí nemůžeme použít funkce na vysoké úrovni, které bychom normálně použili (jako je token.faucet()), protože nedodržujeme ABI.
Místo toho musíme transakci sestavit sami a poté ji odeslat.
to: cdi.address,
data: "0x01"
Pro transakci musíme poskytnout dva parametry:
to, cílová adresa. Toto je kontrakt interpretu dat volání.data, data volání k odeslání. V případě volání faucetu jsou data tvořena jediným bajtem,0x01.
}
await (await signer.sendTransaction(faucetTx)).wait()
Voláme metodu sendTransaction podepisujícího (signer) (opens in a new tab), protože jsme již specifikovali cíl (faucetTx.to) a potřebujeme, aby byla transakce podepsána.
// Zkontrolovat, zda faucet poskytuje tokeny správně
expect(await token.balanceOf(signer.address)).to.equal(1000)
Zde ověříme zůstatek.
U funkcí view není potřeba šetřit gas, takže je prostě spustíme normálně.
// Poskytnout CDI povolený limit (schválení nelze provádět přes proxy)
const approveTX = await token.approve(cdi.address, 10000)
await approveTX.wait()
expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)
Poskytnutí povoleného limitu (allowance) interpretu dat volání, aby mohl provádět převody.
// Převést tokeny
const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
const transferTx = {
to: cdi.address,
data: "0x02" + destAddr.slice(2, 42) + "0100",
}
Vytvoření transakce převodu. První bajt je „0x02“, následovaný cílovou adresou a nakonec částkou (0x0100, což je 256 v desítkové soustavě).
await (await signer.sendTransaction(transferTx)).wait()
// Zkontrolovat, že máme o 256 tokenů méně
expect (await token.balanceOf(signer.address)).to.equal(1000-256)
// A že je náš cíl obdržel
expect (await token.balanceOf(destAddr)).to.equal(256)
}) // it
}) // describe
Snižování nákladů, když máte pod kontrolou cílový kontrakt
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 dat volání.
Příklad toho, jak to funguje, můžete vidět 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 skládatelnost. Je mnohem lepší mít kontrakt, který reaguje na normální volání ERC-20, a další kontrakt, který reaguje na transakce s krátkými daty volání.
Token.sol
V tomto příkladu můžeme upravit Token.sol.
To nám umožňuje mít řadu funkcí, které smí volat pouze proxy kontrakt.
Zde jsou nové části:
// Jediná adresa s oprávněním určit adresu CalldataInterpreter
address owner;
// Adresa CalldataInterpreter
address proxy = address(0);
Kontrakt ERC-20 potřebuje znát identitu autorizovaného proxy kontraktu. 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 kontrakt očekává adresu tokenu ve svém konstruktoru.
/**
* @dev Volá konstruktor ERC-20.
*/
constructor(
) ERC20("Oris useless token-2", "OUT-2") {
owner = msg.sender;
}
Adresa tvůrce (nazývaná owner) je uložena zde, protože to je jediná adresa, která má povoleno nastavit proxy kontrakt.
/**
* @dev nastaví adresu pro proxy (CalldataInterpreter).
* Může být voláno pouze jednou vlastníkem
*/
function setProxy(address _proxy) external {
require(msg.sender == owner, "Can only be called by owner");
require(proxy == address(0), "Proxy is already set");
proxy = _proxy;
} // function setProxy
Proxy kontrakt má privilegovaný přístup, protože může obejít bezpečnostní kontroly.
Abychom se ujistili, že můžeme proxy kontraktu důvěřovat, necháme tuto funkci zavolat pouze owner, a to pouze jednou.
Jakmile má proxy skutečnou hodnotu (nenulovou), tato hodnota se nemůže změnit, takže i když se vlastník rozhodne jednat nepoctivě nebo je odhalena jeho mnemotechnická fráze, jsme stále v bezpečí.
/**
* @dev Některé funkce mohou být volány pouze přes proxy.
*/
modifier onlyProxy {
Toto je funkce modifier (opens in a new tab), upravuje způsob, jakým fungují ostatní funkce.
require(msg.sender == proxy);
Nejprve ověříme, že nás zavolal proxy kontrakt a nikdo jiný.
Pokud ne, revert.
_;
}
Pokud ano, spustíme funkci, kterou upravujeme.
/* Funkce, které umožňují proxy skutečně fungovat jako proxy pro účty */
function transferProxy(address from, address to, uint256 amount)
public virtual onlyProxy() returns (bool)
{
_transfer(from, to, amount);
return true;
}
function approveProxy(address from, address spender, uint256 amount)
public virtual onlyProxy() returns (bool)
{
_approve(from, spender, amount);
return true;
}
function transferFromProxy(
address spender,
address from,
address to,
uint256 amount
) public virtual onlyProxy() returns (bool)
{
_spendAllowance(from, spender, amount);
_transfer(from, to, amount);
return true;
}
Toto jsou tři operace, které normálně vyžadují, aby zpráva pocházela přímo od subjektu převádějícího tokeny nebo schvalujícího povolený limit. Zde máme proxy verzi těchto operací, která:
- Je upravena pomocí
onlyProxy(), takže je nikdo jiný nesmí ovládat. - Získá adresu, která by normálně byla
msg.sender, jako další parametr.
CalldataInterpreter.sol
Interpret dat volání je téměř identický s tím výše, s tím rozdílem, že proxy funkce přijímají parametr msg.sender a není potřeba povolený limit pro transfer.
// převod (není potřeba povolený limit)
if (_func == 2) {
token.transferProxy(
msg.sender,
address(uint160(calldataVal(1, 20))),
calldataVal(21, 2)
);
}
// approve
if (_func == 3) {
token.approveProxy(
msg.sender,
address(uint160(calldataVal(1, 20))),
calldataVal(21, 2)
);
}
// transferFrom
if (_func == 4) {
token.transferFromProxy(
msg.sender,
address(uint160(calldataVal( 1, 20))),
address(uint160(calldataVal(21, 20))),
calldataVal(41, 2)
);
}
Test.js
Mezi předchozím testovacím kódem a tímto je několik změn.
const Cdi = await ethers.getContractFactory("CalldataInterpreter")
const cdi = await Cdi.deploy(token.address)
await cdi.deployed()
await token.setProxy(cdi.address)
Musíme kontraktu ERC-20 říct, kterému proxy kontraktu má důvěřovat
console.log("CalldataInterpreter addr:", cdi.address)
// K ověření povolených limitů jsou potřeba dva podepisující
const signers = await ethers.getSigners()
const signer = signers[0]
const poorSigner = signers[1]
Ke kontrole approve() a transferFrom() potřebujeme druhého podepisujícího.
Nazýváme ho poorSigner, protože nedostane žádné z našich tokenů (samozřejmě ale musí mít ETH).
// Převést tokeny
const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
const transferTx = {
to: cdi.address,
data: "0x02" + destAddr.slice(2, 42) + "0100",
}
await (await signer.sendTransaction(transferTx)).wait()
Protože kontrakt ERC-20 důvěřuje proxy kontraktu (cdi), nepotřebujeme povolený limit k předávání převodů.
// schválení a transferFrom
const approveTx = {
to: cdi.address,
data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",
}
await (await signer.sendTransaction(approveTx)).wait()
const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"
const transferFromTx = {
to: cdi.address,
data: "0x04" + signer.address.slice(2, 42) + destAddr2.slice(2, 42) + "00FF",
}
await (await poorSigner.sendTransaction(transferFromTx)).wait()
// Zkontrolovat, zda byla kombinace approve / transferFrom provedena správně
expect(await token.balanceOf(destAddr2)).to.equal(255)
Otestování dvou nových funkcí.
Všimněte si, že transferFromTx vyžaduje dva parametry adresy: poskytovatele povoleného limitu a příjemce.
Závěr
Sítě Optimism (opens in a new tab) i Arbitrum (opens in a new tab) hledají způsoby, jak snížit velikost dat volání zapisovaných na l1, a tím i náklady na transakce. Nicméně jako poskytovatelé infrastruktury hledající obecná řešení jsou naše možnosti omezené. Jako vývojář decentralizované aplikace (dapp) máte znalosti specifické pro danou aplikaci, což vám umožňuje optimalizovat vaše data volání mnohem lépe, než bychom to dokázali my 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.