Přejít na hlavní obsah

Vše, co můžete cachovat

vrstva 2
cachování
úložiště
škálování
Středně pokročilý
Ori Pomerantz
15. září 2022
21 minut čtení

Při používání rollupů je cena bajtu v transakci mnohem vyšší než cena úložného slotu. Proto dává smysl cachovat co nejvíce informací onchain.

V tomto článku se dozvíte, jak vytvořit a používat cachovací kontrakt takovým způsobem, aby jakákoli hodnota parametru, u které je pravděpodobné, že bude použita vícekrát, byla uložena do mezipaměti (cache) a byla k dispozici pro použití (po prvním použití) s mnohem menším počtem bajtů, a jak napsat offchain kód, který tuto cache využívá.

Pokud chcete článek přeskočit a podívat se rovnou na zdrojový kód, najdete ho zde (opens in a new tab). Vývojový stack je Foundry (opens in a new tab).

Celkový návrh

Pro zjednodušení budeme předpokládat, že všechny parametry transakce jsou uint256, tedy 32 bajtů dlouhé. Když přijmeme transakci, zpracujeme každý parametr takto:

  1. Pokud je první bajt 0xFF, vezměte dalších 32 bajtů jako hodnotu parametru a zapište ji do cache.

  2. Pokud je první bajt 0xFE, vezměte dalších 32 bajtů jako hodnotu parametru, ale nezapisujte ji do cache.

  3. Pro jakoukoli jinou hodnotu vezměte horní čtyři bity jako počet dalších bajtů a dolní čtyři bity jako nejvýznamnější bity klíče cache. Zde je několik příkladů:

    Bajty v datech voláníKlíč cache
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

Manipulace s cache

Cache je implementována v Cache.sol (opens in a new tab). Pojďme si to projít řádek po řádku.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;


contract Cache {

    bytes1 public constant INTO_CACHE = 0xFF;
    bytes1 public constant DONT_CACHE = 0xFE;

Tyto konstanty se používají k interpretaci speciálních případů, kdy poskytneme všechny informace a buď je chceme zapsat do cache, nebo ne. Zápis do cache vyžaduje dvě operace SSTORE (opens in a new tab) do dříve nepoužitých úložných slotů za cenu 22 100 gasu za každou, takže to děláme volitelné.


    mapping(uint => uint) public val2key;

Mapování (opens in a new tab) mezi hodnotami a jejich klíči. Tato informace je nezbytná pro zakódování hodnot před odesláním transakce.

    // Umístění n má hodnotu pro klíč n+1, protože potřebujeme zachovat
    // nulu jako „není v mezipaměti“.
    uint[] public key2val;

Pro mapování z klíčů na hodnoty můžeme použít pole, protože klíče přiřazujeme my a pro zjednodušení to děláme sekvenčně.

    function cacheRead(uint _key) public view returns (uint) {
        require(_key <= key2val.length, "Reading uninitialize cache entry");
        return key2val[_key-1];
    }  // cacheRead

Přečtení hodnoty z cache.

    // Zapsat hodnotu do mezipaměti, pokud tam ještě není
    // Pouze public, aby mohl fungovat test
    function cacheWrite(uint _value) public returns (uint) {
        // Pokud je hodnota již v mezipaměti, vrátit aktuální klíč
        if (val2key[_value] != 0) {
            return val2key[_value];
        }

Nemá smysl vkládat stejnou hodnotu do cache vícekrát. Pokud tam hodnota již je, stačí vrátit existující klíč.

        // Jelikož 0xFE je speciální případ, největší klíč, který může mezipaměť
        // pojmout, je 0x0D následované 15 0xFF. Pokud je délka mezipaměti již takto
        // velká, selhat.
        //                              1 2 3 4 5 6 7 8 9 A B C D E F
        require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
            "cache overflow");

Nemyslím si, že někdy budeme mít tak velkou cache (přibližně 1,8*1037 záznamů, což by vyžadovalo asi 1027 TB k uložení). Jsem však dost starý na to, abych si pamatoval, že „640 kB bude vždy stačit“ (opens in a new tab). Tento test je velmi levný.

        // Zapsat hodnotu pomocí dalšího klíče
        val2key[_value] = key2val.length+1;

Přidání zpětného vyhledávání (od hodnoty ke klíči).

        key2val.push(_value);

Přidání dopředného vyhledávání (od klíče k hodnotě). Protože hodnoty přiřazujeme sekvenčně, můžeme je jednoduše přidat za poslední hodnotu pole.

        return key2val.length;
    }  // cacheWrite

Vrácení nové délky key2val, což je buňka, kde je uložena nová hodnota.

    function _calldataVal(uint startByte, uint length)
        private pure returns (uint)

Tato funkce čte hodnotu z dat volání libovolné délky (až 32 bajtů, velikost slova).

    {
        uint _retVal;

        require(length < 0x21,
            "_calldataVal length limit is 32 bytes");
        require(length + startByte <= msg.data.length,
            "_calldataVal trying to read beyond calldatasize");

Tato funkce je interní, takže pokud je zbytek kódu napsán správně, tyto testy nejsou nutné. Nicméně nestojí mnoho, takže je můžeme klidně ponechat.

        assembly {
            _retVal := calldataload(startByte)
        }

Tento kód je v jazyce Yul (opens in a new tab). Čte 32bajtovou hodnotu z dat volání. To funguje, i když data volání skončí před startByte+32, protože neinicializovaný prostor v EVM je považován za nulu.

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

Ne nutně chceme 32bajtovou hodnotu. Tímto se zbavíme přebytečných bajtů.

        return _retVal;
    } // _calldataVal


    // Přečíst jeden parametr z dat volání, počínaje od _fromByte
    function _readParam(uint _fromByte) internal
        returns (uint _nextByte, uint _parameterValue)
    {

Přečtení jednoho parametru z dat volání. Všimněte si, že musíme vrátit nejen přečtenou hodnotu, ale také pozici dalšího bajtu, protože parametry mohou mít délku od 1 bajtu do 33 bajtů.

        // První bajt nám říká, jak interpretovat zbytek
        uint8 _firstByte;

        _firstByte = uint8(_calldataVal(_fromByte, 1));

Solidity se snaží snížit počet chyb tím, že zakazuje potenciálně nebezpečné implicitní typové konverze (opens in a new tab). Snížení, například z 256 bitů na 8 bitů, musí být explicitní.

Vezměte spodní půlbajt (nibble) (opens in a new tab) a zkombinujte jej s ostatními bajty pro přečtení hodnoty z cache.

Počet parametrů bychom mohli získat ze samotných dat volání, ale funkce, které nás volají, vědí, kolik parametrů očekávají. Je jednodušší nechat je, ať nám to řeknou.

Čtěte parametry, dokud nebudete mít potřebný počet. Pokud překročíme konec dat volání, _readParams volání zvrátí.

Jednou z velkých výhod Foundry je, že umožňuje psát testy v Solidity (viz Testování cache níže). To značně usnadňuje jednotkové testy (unit tests). Toto je funkce, která přečte čtyři parametry a vrátí je, aby test mohl ověřit, že byly správné.

    // Získat hodnotu, vrátit bajty, které ji zakódují (s využitím mezipaměti, pokud je to možné)
    function encodeVal(uint _val) public view returns(bytes memory) {

encodeVal je funkce, kterou volá offchain kód, aby pomohla vytvořit data volání využívající cache. Přijme jednu hodnotu a vrátí bajty, které ji kódují. Tato funkce je view, takže nevyžaduje transakci a při externím volání nestojí žádný gas.

        uint _key = val2key[_val];

        // Hodnota ještě není v mezipaměti, přidat ji
        if (_key == 0)
            return bytes.concat(INTO_CACHE, bytes32(_val));

V EVM se předpokládá, že veškeré neinicializované úložiště obsahuje nuly. Pokud tedy hledáme klíč pro hodnotu, která tam není, dostaneme nulu. V takovém případě jsou bajty, které ji kódují, INTO_CACHE (takže bude příště uložena do cache), následované samotnou hodnotou.

        // Pokud je klíč <0x10, vrátit jej jako jeden bajt
        if (_key < 0x10)
            return bytes.concat(bytes1(uint8(_key)));

Jednotlivé bajty jsou nejjednodušší. Stačí použít bytes.concat (opens in a new tab) k přeměně typu bytes<n> na pole bajtů, které může mít libovolnou délku. Navzdory názvu to funguje dobře, i když je poskytnut pouze jeden argument.

        // Dvoubajtová hodnota, zakódovaná jako 0x1vvv
        if (_key < 0x1000)
            return bytes.concat(bytes2(uint16(_key) | 0x1000));

Když máme klíč, který je menší než 163, můžeme jej vyjádřit ve dvou bajtech. Nejprve převedeme _key, což je 256bitová hodnota, na 16bitovou hodnotu a použijeme logické OR k přidání počtu bajtů navíc k prvnímu bajtu. Pak to jen převedeme na hodnotu bytes2, kterou lze převést na bytes.

Ostatní hodnoty (3 bajty, 4 bajty atd.) se zpracovávají stejným způsobem, jen s jinými velikostmi polí.

        // Pokud se dostaneme sem, něco je špatně.
        revert("Error in encodeVal, should not happen");

Pokud se dostaneme sem, znamená to, že jsme dostali klíč, který není menší než 16*25615. Ale cacheWrite omezuje klíče, takže se nemůžeme dostat ani na 14*25616 (což by mělo první bajt 0xFE, takže by to vypadalo jako DONT_CACHE). Nestojí nás ale moc přidat test pro případ, že by budoucí programátor zanesl chybu.

    } // encodeVal

}  // Cache

Testování cache

Jednou z výhod Foundry je, že vám umožňuje psát testy v Solidity (opens in a new tab), což usnadňuje psaní jednotkových testů. Testy pro třídu Cache jsou zde (opens in a new tab). Protože se testovací kód opakuje, jak už to u testů bývá, tento článek vysvětluje pouze zajímavé části.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";


// Pro konzoli je nutné spustit `forge test -vv`.
import "forge-std/console.sol";

Toto je jen standardní kód (boilerplate), který je nezbytný pro použití testovacího balíčku a console.log.

import "src/Cache.sol";

Potřebujeme znát kontrakt, který testujeme.

contract CacheTest is Test {
    Cache cache;

    function setUp() public {
        cache = new Cache();
    }

Funkce setUp se volá před každým testem. V tomto případě jen vytvoříme novou cache, aby se naše testy navzájem neovlivňovaly.

    function testCaching() public {

Testy jsou funkce, jejichž názvy začínají na test. Tato funkce kontroluje základní funkčnost cache, zápis hodnot a jejich opětovné čtení.

        for(uint i=1; i<5000; i++) {
            cache.cacheWrite(i*i);
        }

        for(uint i=1; i<5000; i++) {
            assertEq(cache.cacheRead(i), i*i);

Takto se provádí samotné testování pomocí funkcí assert... (opens in a new tab). V tomto případě kontrolujeme, zda hodnota, kterou jsme zapsali, je ta, kterou jsme přečetli. Výsledek cache.cacheWrite můžeme zahodit, protože víme, že klíče cache jsou přiřazovány lineárně.

Nejprve zapíšeme každou hodnotu do cache dvakrát a ujistíme se, že klíče jsou stejné (což znamená, že k druhému zápisu ve skutečnosti nedošlo).

        for(uint i=1; i<100; i+=3) {
            uint _key = cache.cacheWrite(i);
            assertEq(_key, i);
        }
    }    // testRepeatCaching

Teoreticky by mohla existovat chyba, která neovlivňuje po sobě jdoucí zápisy do cache. Takže zde provedeme několik zápisů, které nenásledují po sobě, a vidíme, že hodnoty se stále nepřepisují.

    // Přečíst uint z paměťového bufferu (abychom se ujistili, že dostaneme zpět parametry,
    // které jsme odeslali)
    function toUint256(bytes memory _bytes, uint256 _start) internal pure
        returns (uint256)

Přečtení 256bitového slova z bufferu bytes memory. Tato pomocná funkce nám umožňuje ověřit, že při spuštění volání funkce, která využívá cache, obdržíme správné výsledky.

    {
        require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
        uint256 tempUint;

        assembly {
            tempUint := mload(add(add(_bytes, 0x20), _start))
        }

Yul nepodporuje datové struktury nad rámec uint256, takže když odkazujete na sofistikovanější datovou strukturu, jako je paměťový buffer _bytes, získáte adresu této struktury. Solidity ukládá hodnoty bytes memory jako 32bajtové slovo, které obsahuje délku, následované samotnými bajty, takže abychom získali bajt číslo _start, musíme vypočítat _bytes+32+_start.

Některé konstanty, které potřebujeme pro testování.

    function testReadParam() public {

Zavolání fourParams(), funkce, která používá readParams, abychom otestovali, že dokážeme správně číst parametry.

        address _cacheAddr = address(cache);
        bool _success;
        bytes memory _callInput;
        bytes memory _callOutput;

K volání funkce využívající cache nemůžeme použít normální mechanismus ABI, takže musíme použít nízkoúrovňový mechanismus <address>.call() (opens in a new tab). Tento mechanismus bere jako vstup bytes memory a vrací jej (stejně jako booleovskou hodnotu) jako výstup.

        // První volání, mezipaměť je prázdná
        _callInput = bytes.concat(
            FOUR_PARAMS,

Je užitečné, aby stejný kontrakt podporoval jak cachované funkce (pro volání přímo z transakcí), tak necachované funkce (pro volání z jiných chytrých kontraktů). K tomu se musíme i nadále spoléhat na mechanismus Solidity pro volání správné funkce, místo abychom vše vkládali do funkce fallback (opens in a new tab). Tím se výrazně usnadní skládatelnost. K identifikaci funkce by ve většině případů stačil jeden bajt, takže plýtváme třemi bajty (16*3=48 gasu). Nicméně v době psaní tohoto textu stojí těchto 48 gasu 0,07 centu, což je rozumná cena za jednodušší kód, který je méně náchylný k chybám.

            // První hodnota, přidat ji do mezipaměti
            cache.INTO_CACHE(),
            bytes32(VAL_A),

První hodnota: Příznak říkající, že jde o plnou hodnotu, kterou je třeba zapsat do cache, následovaný 32 bajty hodnoty. Další tři hodnoty jsou podobné, s tím rozdílem, že VAL_B se do cache nezapisuje a VAL_C je třetím i čtvrtým parametrem.

             .
             .
             .
        );
        (_success, _callOutput) = _cacheAddr.call(_callInput);

Zde skutečně voláme kontrakt Cache.

        assertEq(_success, true);

Očekáváme, že volání bude úspěšné.

        assertEq(cache.cacheRead(1), VAL_A);
        assertEq(cache.cacheRead(2), VAL_C);

Začneme s prázdnou cache a poté přidáme VAL_A následované VAL_C. Očekávali bychom, že první bude mít klíč 1 a druhé 2.

assertEq(toUint256(_callOutput,0), VAL_A);
        assertEq(toUint256(_callOutput,32), VAL_B);
        assertEq(toUint256(_callOutput,64), VAL_C);
        assertEq(toUint256(_callOutput,96), VAL_C);

Výstupem jsou čtyři parametry. Zde ověřujeme, že je to správně.

        // Druhé volání, můžeme použít mezipaměť
        _callInput = bytes.concat(
            FOUR_PARAMS,

            // První hodnota v mezipaměti
            bytes1(0x01),

Klíče cache menší než 16 mají pouze jeden bajt.

Testy po volání jsou identické s těmi po prvním volání.

    function testEncodeVal() public {

Tato funkce je podobná testReadParam, s tím rozdílem, že místo explicitního zápisu parametrů používáme encodeVal().

Jediným dalším testem v testEncodeVal() je ověření, že délka _callInput je správná. Pro první volání je to 4+33*4. Pro druhé, kde je každá hodnota již v cache, je to 4+1*4.

Výše uvedená funkce testEncodeVal zapisuje do cache pouze čtyři hodnoty, takže část funkce, která se zabývá vícedílnými bajtovými hodnotami (opens in a new tab), není kontrolována. Tento kód je ale složitý a náchylný k chybám.

První částí této funkce je smyčka, která postupně zapisuje všechny hodnoty od 1 do 0x1FFF do cache, takže budeme schopni tyto hodnoty zakódovat a vědět, kam směřují.

Otestujte jednobajtové, dvoubajtové a tříbajtové hodnoty. Dále netestujeme, protože by trvalo příliš dlouho zapsat dostatek položek zásobníku (alespoň 0x10000000, přibližně čtvrt miliardy).

Otestujte, co se stane v abnormálním případě, kdy není dostatek parametrů.

        .
        .
        .
        (_success, _callOutput) = _cacheAddr.call(_callInput);
        assertEq(_success, false);
    }   // testShortCalldata

Vzhledem k tomu, že se zvrátí, výsledek, který bychom měli dostat, je false.

Tato funkce dostane čtyři naprosto legitimní parametry, s tím rozdílem, že cache je prázdná, takže v ní nejsou žádné hodnoty ke čtení.

Tato funkce odesílá pět hodnot. Víme, že pátá hodnota je ignorována, protože to není platný záznam v cache, což by způsobilo zvrácení, kdyby nebyla zahrnuta.

Ukázková aplikace

Psaní testů v Solidity je sice hezké, ale na konci dne musí být decentralizovaná aplikace (dapp) schopna zpracovávat požadavky mimo řetězec, aby byla užitečná. Tento článek ukazuje, jak používat cachování v dapp s WORM, což znamená „Write Once, Read Many“ (Zapiš jednou, čti mnohokrát). Pokud klíč ještě není zapsán, můžete do něj zapsat hodnotu. Pokud je klíč již zapsán, dojde ke zvrácení.

Kontrakt

Toto je kontrakt (opens in a new tab). Většinou opakuje to, co jsme již udělali s Cache a CacheTest, takže se budeme věnovat pouze zajímavým částem.

import "./Cache.sol";

contract WORM is Cache {

Nejjednodušší způsob, jak použít Cache, je zdědit jej v našem vlastním kontraktu.

    function writeEntryCached() external {
        uint[] memory params = _readParams(2);
        writeEntry(params[0], params[1]);
    }    // writeEntryCached

Tato funkce je podobná fourParam v CacheTest výše. Protože se neřídíme specifikacemi ABI, je nejlepší do funkce nedeklarovat žádné parametry.

    // Usnadnit naše volání
    // Signatura funkce pro writeEntryCached(), s laskavým svolením
    // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
    bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;

Externí kód, který volá writeEntryCached, bude muset ručně sestavit data volání místo použití worm.writeEntryCached, protože se neřídíme specifikacemi ABI. Mít tuto konstantní hodnotu jen usnadňuje její zápis.

Všimněte si, že i když definujeme WRITE_ENTRY_CACHED jako stavovou proměnnou, pro její externí čtení je nutné použít její getter funkci, worm.WRITE_ENTRY_CACHED().

    function readEntry(uint key) public view
        returns (uint _value, address _writtenBy, uint _writtenAtBlock)

Funkce pro čtení je view, takže nevyžaduje transakci a nestojí žádný gas. V důsledku toho nemá použití cache pro parametr žádnou výhodu. U view funkcí je nejlepší použít standardní mechanismus, který je jednodušší.

Testovací kód

Toto je testovací kód pro kontrakt (opens in a new tab). Opět se podívejme pouze na to, co je zajímavé.

    function testWReadWrite() public {
        worm.writeEntry(0xDEAD, 0x60A7);

        vm.expectRevert(bytes("entry already written"));
        worm.writeEntry(0xDEAD, 0xBEEF);

Takto (vm.expectRevert) (opens in a new tab) ve Foundry testu specifikujeme, že další volání by mělo selhat, a nahlášený důvod selhání. To platí, když používáme syntaxi <contract>.<function name>() místo sestavování dat volání a volání kontraktu pomocí nízkoúrovňového rozhraní (<contract>.call() atd.).

    function testReadWriteCached() public {
        uint cacheGoat = worm.cacheWrite(0x60A7);

Zde využíváme skutečnosti, že cacheWrite vrací klíč cache. To není něco, co bychom očekávali, že použijeme v produkci, protože cacheWrite mění stav, a proto může být voláno pouze během transakce. Transakce nemají návratové hodnoty, pokud mají výsledky, předpokládá se, že tyto výsledky budou emitovány jako události. Návratová hodnota cacheWrite je tedy přístupná pouze z onchain kódu a onchain kód nepotřebuje cachování parametrů.

        (_success,) = address(worm).call(_callInput);

Takto řekneme Solidity, že ačkoli má <contract address>.call() dvě návratové hodnoty, zajímá nás pouze ta první.

        (_success,) = address(worm).call(_callInput);
        assertEq(_success, false);

Vzhledem k tomu, že používáme nízkoúrovňovou funkci <address>.call(), nemůžeme použít vm.expectRevert() a musíme se podívat na booleovskou hodnotu úspěchu, kterou získáme z volání.

Tímto způsobem ověřujeme, že kód ve Foundry správně emituje událost (opens in a new tab).

Klient

Jednou z věcí, kterou u testů v Solidity nezískáte, je kód v JavaScriptu, který byste mohli zkopírovat a vložit do své vlastní aplikace. Původní verze tohoto tutoriálu nasadila WORM na síť Optimism Goerli, která však již byla ukončena. Chcete-li klienta spustit dnes, nasaďte WORM znovu na podporovanou síť OP Stack, jako je OP Sepolia (opens in a new tab), a poté použijte výslednou adresu kontraktu v klientovi v JavaScriptu.

Kód v JavaScriptu pro klienta si můžete prohlédnout zde (opens in a new tab). Ukázkový repozitář byl napsán pro Optimism Goerli, takže před jeho spuštěním aktualizujte koncový bod RPC a URL adresy prohlížeče bloků v souborech javascript/.env.example a javascript/index.js pro vaši cílovou síť. Chcete-li jej použít:

  1. Naklonujte git repozitář:

    git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
    
  2. Nainstalujte potřebné balíčky:

    cd javascript
    yarn
    
  3. Zkopírujte konfigurační soubor:

    cp .env.example .env
    
  4. Upravte soubor .env podle vaší konfigurace:

    ParametrHodnota
    MNEMONICMnemotechnická pomůcka (seed) pro účet, který má dostatek ETH na zaplacení transakce. Dokumentace k faucetům sítě Optimism (opens in a new tab) uvádí aktuální faucety pro testnet.
    OPTIMISM_GOERLI_URLRPC URL pro síť, do které znovu nasadíte WORM. Pro OP Sepolia použijte koncový bod RPC pro OP Sepolia, například https://sepolia.optimism.io, nebo jiný koncový bod od vašeho poskytovatele.
  5. Spusťte index.js.

    node index.js
    

    Tato ukázková aplikace nejprve zapíše záznam do WORM, přičemž zobrazí data volání a odkaz na transakci v prohlížeči bloků. Poté tento záznam přečte zpět a zobrazí klíč, který používá, a hodnoty v záznamu (hodnotu, číslo bloku a autora).

Většina klienta je běžný JavaScript pro decentralizovanou aplikaci (dapp). Takže si opět projdeme jen ty zajímavé části.

.
.
.
const main = async () => {
    const func = await worm.WRITE_ENTRY_CACHED()

    // Pokaždé potřebujeme nový klíč
    const key = await worm.encodeVal(Number(new Date()))

Do daného slotu lze zapisovat pouze jednou, takže používáme časové razítko, abychom se ujistili, že sloty nepoužijeme znovu.

const val = await worm.encodeVal("0x600D")

// Zapsat záznam
const calldata = func + key.slice(2) + val.slice(2)

Knihovna Ethers očekává, že data volání budou hexadecimální řetězec, tedy 0x následované sudým počtem hexadecimálních číslic. Protože key i val začínají na 0x, musíme tyto hlavičky odstranit.

const tx = await worm.populateTransaction.writeEntryCached()
tx.data = calldata

sentTx = await wallet.sendTransaction(tx)

Stejně jako u testovacího kódu v Solidity nemůžeme cachovanou funkci volat normálně. Místo toho musíme použít nízkoúrovňovější mechanismus.

Pro čtení záznamů můžeme použít normální mechanismus. U view funkcí není nutné používat cachování parametrů.

Závěr

Kód v tomto článku je proof of concept (ověření konceptu), jehož účelem je usnadnit pochopení této myšlenky. Pro systém připravený do produkce byste možná chtěli implementovat některé další funkce:

  • Zpracování hodnot, které nejsou uint256. Například řetězce.

  • Místo globální cache mít možná mapování mezi uživateli a cachemi. Různí uživatelé používají různé hodnoty.

  • Hodnoty používané pro adresy se liší od hodnot používaných pro jiné účely. Možná by dávalo smysl mít samostatnou cache jen pro adresy.

  • V současné době jsou klíče cache založeny na algoritmu „kdo dřív přijde, má nejmenší klíč“. Prvních šestnáct hodnot lze odeslat jako jeden bajt. Dalších 4080 hodnot lze odeslat jako dva bajty. Další přibližně milion hodnot jsou tři bajty atd. Produkční systém by měl udržovat počítadla využití záznamů v cache a reorganizovat je tak, aby šestnáct nejběžnějších hodnot mělo jeden bajt, dalších 4080 nejběžnějších hodnot dva bajty atd.

    To je však potenciálně nebezpečná operace. Představte si následující sled událostí:

    1. Naivní Noam zavolá encodeVal, aby zakódoval adresu, na kterou chce poslat tokeny. Tato adresa je jednou z prvních použitých v aplikaci, takže zakódovaná hodnota je 0x06. Jedná se o funkci view, nikoli o transakci, takže probíhá mezi Noamem a uzlem, který používá, a nikdo jiný o tom neví.

    2. Majitel Owen spustí operaci změny pořadí v cache. Tuto adresu ve skutečnosti používá jen velmi málo lidí, takže je nyní zakódována jako 0x201122. Jiné hodnotě, 1018, je přiřazeno 0x06.

    3. Naivní Noam pošle své tokeny na 0x06. Ty jdou na adresu 0x0000000000000000000000000de0b6b3a7640000, a protože nikdo nezná soukromý klíč k této adrese, prostě tam uvíznou. Noam není nadšený.

    Existují způsoby, jak tento problém vyřešit, stejně jako související problém transakcí, které jsou v mempoolu během změny pořadí v cache, ale musíte si toho být vědomi.

Cachování jsem zde demonstroval na síti Optimism, protože jsem zaměstnancem Optimism a toto je rollup, který znám nejlépe. Mělo by to ale fungovat s jakýmkoli rollupem, který si účtuje minimální náklady na interní zpracování, takže ve srovnání s tím je zápis transakčních dat na vrstvu 1 (l1) hlavním výdajem.

Zde najdete další mou práci (opens in a new tab).