Přeskočit na hlavní obsah

Vše, co můžete cachovat

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

Při používání rollupů je cena jednoho bajtu v transakci o mnoho vyšší než cena slotu v úložišti. Proto dává smysl cachovat co nejvíce informací na blockchainu.

V tomto článku se naučíte, jak vytvořit a používat cachovací kontrakt tak, aby se každá hodnota parametru, která se pravděpodobně použije vícekrát, uložila do cache a byla (po prvním použití) dostupná za použití mnohem menšího počtu bajtů, a jak napsat off-chain 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 zdeopens in a new tab. Vývojový stack je Foundryopens in a new tab.

Celkový návrh

Pro zjednodušení budeme předpokládat, že všechny parametry transakce jsou typu uint256 o délce 32 bajtů. Když obdržíme transakci, zpracujeme každý parametr následujícím způsobem:

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

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

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

    Bajty v calldataKlíč cache
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

Manipulace s cache

Cache je implementována v souboru Cache.solopens in a new tab. Pojďme si ho projít řádek po řádku.

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4
5contract Cache {
6
7 bytes1 public constant INTO_CACHE = 0xFF;
8 bytes1 public constant DONT_CACHE = 0xFE;

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

1
2 mapping(uint => uint) public val2key;

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

1 // Umístění n má hodnotu pro klíč n+1, protože potřebujeme zachovat
2 // nulu jako „není v cache“.
3 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ě.

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

Přečte hodnotu z cache.

1 // Zapíše hodnotu do cache, pokud tam ještě není
2 // Veřejné jen proto,aby fungoval test
3 function cacheWrite(uint _value) public returns (uint) {
4 // Pokud je hodnota již v cache, vrátí aktuální klíč
5 if (val2key[_value] != 0) {
6 return val2key[_value];
7 }

Nemá smysl vkládat stejnou hodnotu do cache více než jednou. Pokud tam hodnota již je, stačí vrátit stávající klíč.

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

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

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

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

1 key2val.push(_value);

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

1 return key2val.length;
2 } // cacheWrite

Vrátí novou délku key2val, což je buňka, kde je uložena nová hodnota.

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

Tato funkce čte hodnotu z calldata libovolné délky (až 32 bajtů, což je velikost slova).

1 {
2 uint _retVal;
3
4 require(length < 0x21,
5 "limit délky _calldataVal je 32 bajtů");
6 require(length + startByte <= msg.data.length,
7 "_calldataVal se snaží číst za calldatasize");

Tato funkce je interní, takže pokud je zbytek kódu napsán správně, tyto testy nejsou nutné. Nestojí však mnoho, takže je můžeme klidně použít.

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

Tento kód je v jazyce Yulopens in a new tab. Čte 32bajtovou hodnotu z calldata. Funguje to i v případě, že calldata končí před startByte+32, protože neinicializovaný prostor v EVM je považován za nulový.

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

Nemusíme nutně chtít 32bajtovou hodnotu. Tím se zbavíme přebytečných bajtů.

1 return _retVal;
2 } // _calldataVal
3
4
5 // Načte jeden parametr z calldata, počínaje od _fromByte
6 function _readParam(uint _fromByte) internal
7 returns (uint _nextByte, uint _parameterValue)
8 {

Načte jeden parametr z calldata. Všimněte si, že musíme vrátit nejen hodnotu, kterou jsme načetli, ale také umístění dalšího bajtu, protože parametry mohou mít délku od 1 do 33 bajtů.

1 // První bajt nám říká, jak interpretovat zbytek
2 uint8 _firstByte;
3
4 _firstByte = uint8(_calldataVal(_fromByte, 1));

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

1
2 // Přečte hodnotu, ale nezapíše ji do cache
3 if (_firstByte == uint8(DONT_CACHE))
4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));
5
6 // Přečte hodnotu a zapíše ji do cache
7 if (_firstByte == uint8(INTO_CACHE)) {
8 uint _param = _calldataVal(_fromByte+1, 32);
9 cacheWrite(_param);
10 return(_fromByte+33, _param);
11 }
12
13 // Pokud jsme se dostali sem, znamená to, že musíme číst z cache
14
15 // Počet bajtů navíc ke čtení
16 uint8 _extraBytes = _firstByte / 16;
Zobrazit vše

Vezměte nižší půlbajtopens in a new tab a zkombinujte ho s ostatními bajty, abyste načetli hodnotu z cache.

1 uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +
2 _calldataVal(_fromByte+1, _extraBytes);
3
4 return (_fromByte+_extraBytes+1, cacheRead(_key));
5
6 } // _readParam
7
8
9 // Načte n parametrů (funkce vědí, kolik parametrů očekávají)
10 function _readParams(uint _paramNum) internal returns (uint[] memory) {
Zobrazit vše

Počet parametrů, které máme, bychom mohli získat ze samotné calldata, ale funkce, které nás volají, vědí, kolik parametrů očekávají. Je jednodušší, nechat si to od nich říct.

1 // Parametry, které čteme
2 uint[] memory params = new uint[](_paramNum);
3
4 // Parametry začínají na 4. bajtu, předtím je podpis funkce
5 uint _atByte = 4;
6
7 for(uint i=0; i<_paramNum; i++) {
8 (_atByte, params[i]) = _readParam(_atByte);
9 }
Zobrazit vše

Čtěte parametry, dokud nebudete mít požadovaný počet. Pokud překročíme konec calldata, _readParams vrátí volání zpět.

1
2 return(params);
3 } // readParams
4
5 // Pro testování _readParams, testování čtení čtyř parametrů
6 function fourParam() public
7 returns (uint256,uint256,uint256,uint256)
8 {
9 uint[] memory params;
10 params = _readParams(4);
11 return (params[0], params[1], params[2], params[3]);
12 } // fourParam
Zobrazit vše

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. Jedná se o funkci, která přečte čtyři parametry a vrátí je, aby test mohl ověřit, že byly správné.

1 // Získá hodnotu, vrátí bajty, které ji zakódují (pokud možno s použitím cache)
2 function encodeVal(uint _val) public view returns(bytes memory) {

encodeVal je funkce, kterou volá off-chain kód, aby pomohla vytvořit calldata, která používá cache. Přijímá jednu hodnotu a vrací bajty, které ji kódují. Tato funkce je view, takže nevyžaduje transakci a při externím volání nestojí žádný gas.

1 uint _key = val2key[_val];
2
3 // Hodnota ještě není v cache, přidejte ji
4 if (_key == 0)
5 return bytes.concat(INTO_CACHE, bytes32(_val));

V EVM se předpokládá, že všechna neinicializovaná úložiště jsou nulová. Takže pokud 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ři příštím použití cachována), následované skutečnou hodnotou.

1 // Pokud je klíč <0x10, vraťte ho jako jeden bajt
2 if (_key < 0x10)
3 return bytes.concat(bytes1(uint8(_key)));

Jednotlivé bajty jsou nejjednodušší. Použijeme jen bytes.concatopens in a new tab pro převod typu bytes<n> na pole bajtů, které může mít libovolnou délku. Navzdory svému názvu funguje dobře i při zadání pouze jednoho argumentu.

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

Pokud máme klíč, který je menší než 163, můžeme ho vyjádřit ve dvou bajtech. Nejprve převedeme _key, což je 256bitová hodnota, na 16bitovou hodnotu a pomocí logického součtu přidáme počet bajtů navíc k prvnímu bajtu. Poté ji převedeme na hodnotu bytes2, kterou lze převést na bytes.

1 // Pravděpodobně existuje chytrý způsob, jak provést následující řádky jako smyčku,
2 // ale je to funkce typu view, takže optimalizuji na čas programátora a
3 // jednoduchost.
4
5 if (_key < 16*256**2)
6 return bytes.concat(bytes3(uint24(_key) | (0x2 * 16 * 256**2)));
7 if (_key < 16*256**3)
8 return bytes.concat(bytes4(uint32(_key) | (0x3 * 16 * 256**3)));
9 .
10 .
11 .
12 if (_key < 16*256**14)
13 return bytes.concat(bytes15(uint120(_key) | (0xE * 16 * 256**14)));
14 if (_key < 16*256**15)
15 return bytes.concat(bytes16(uint128(_key) | (0xF * 16 * 256**15)));
Zobrazit vše

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

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

Pokud se dostaneme sem, znamená to, že jsme dostali klíč, který je větší 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). Ale přidání testu pro případ, že budoucí programátor zavede chybu, nás moc nestojí.

1 } // encodeVal
2
3} // Cache

Testování cache

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

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4import "forge-std/Test.sol";
5
6
7// Je potřeba spustit `forge test -vv` pro konzoli.
8import "forge-std/console.sol";

Toto je pouze boilerplate, který je nezbytný pro použití testovacího balíčku a console.log.

1import "src/Cache.sol";

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

1contract CacheTest is Test {
2 Cache cache;
3
4 function setUp() public {
5 cache = new Cache();
6 }

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

1 function testCaching() public {

Testy jsou funkce, jejichž názvy začínají na test. Tato funkce kontroluje základní funkčnost cache, zapisuje hodnoty a znovu je čte.

1 for(uint i=1; i<5000; i++) {
2 cache.cacheWrite(i*i);
3 }
4
5 for(uint i=1; i<5000; i++) {
6 assertEq(cache.cacheRead(i), i*i);

Takto se provádí skutečné testování pomocí funkcí assert...opens in a new tab. V tomto případě kontrolujeme, že 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ě.

1 }
2 } // testCaching
3
4
5 // Cachovat stejnou hodnotu vícekrát, zajistit, aby klíč zůstal
6 // stejný
7 function testRepeatCaching() public {
8 for(uint i=1; i<100; i++) {
9 uint _key1 = cache.cacheWrite(i);
10 uint _key2 = cache.cacheWrite(i);
11 assertEq(_key1, _key2);
12 }
Zobrazit vše

Nejprve zapíšeme každou hodnotu dvakrát do cache a ujistíme se, že klíče jsou stejné (což znamená, že druhý zápis se ve skutečnosti neuskutečnil).

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

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

1 // Přečte uint z bufferu v paměti (abychom se ujistili, že dostaneme zpět parametry,
2 // které jsme odeslali)
3 function toUint256(bytes memory _bytes, uint256 _start) internal pure
4 returns (uint256)

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

1 {
2 require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
3 uint256 tempUint;
4
5 assembly {
6 tempUint := mload(add(add(_bytes, 0x20), _start))
7 }

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é skutečnými bajty, takže pro získání bajtu číslo _start musíme vypočítat _bytes+32+_start.

1
2 return tempUint;
3 } // toUint256
4
5 // Podpis funkce pro fourParams(), s laskavým svolením
6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
8
9 // Jen několik konstantních hodnot, abychom viděli, že dostáváme zpět správné hodnoty
10 uint256 constant VAL_A = 0xDEAD60A7;
11 uint256 constant VAL_B = 0xBEEF;
12 uint256 constant VAL_C = 0x600D;
13 uint256 constant VAL_D = 0x600D60A7;
Zobrazit vše

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

1 function testReadParam() public {

Zavoláním fourParams(), funkce, která používá readParams, otestujeme, zda umíme správně číst parametry.

1 address _cacheAddr = address(cache);
2 bool _success;
3 bytes memory _callInput;
4 bytes memory _callOutput;

Nemůžeme použít normální mechanismus ABI pro volání funkce pomocí cache, takže musíme použít nízkoúrovňový mechanismus <address>.call()opens in a new tab. Tento mechanismus přijímá jako vstup bytes memory a vrací je (stejně jako booleovskou hodnotu) jako výstup.

1 // První volání, cache je prázdná
2 _callInput = bytes.concat(
3 FOUR_PARAMS,

Je užitečné, aby stejný kontrakt podporoval jak cachované funkce (pro volání přímo z transakcí), tak i necachované funkce (pro volání z jiných chytrých kontraktů). Abychom toho dosáhli, musíme se nadále spoléhat na mechanismus Solidity pro volání správné funkce, namísto toho, abychom vše vkládali do funkce fallbackopens in a new tab. Tímto způsobem je kompozitnost mnohem snazší. Jeden bajt by ve většině případů stačil k identifikaci funkce, takže plýtváme třemi bajty (16*3=48 gasu). Nicméně, v době psaní tohoto článku stojí těchto 48 gasů 0,07 centů, což je rozumná cena za jednodušší a méně chybový kód.

1 // První hodnota, přidejte ji do cache
2 cache.INTO_CACHE(),
3 bytes32(VAL_A),

První hodnota: Příznak, který říká, že je to plná hodnota, která se musí zapsat do cache, následovaný 32 bajty hodnoty. Ostatní tři hodnoty jsou podobné, s výjimkou toho, že VAL_B se do cache nezapisuje a VAL_C je jak třetím, tak čtvrtým parametrem.

1 .
2 .
3 .
4 );
5 (_success, _callOutput) = _cacheAddr.call(_callInput);

Zde skutečně voláme kontrakt Cache.

1 assertEq(_success, true);

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

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

Začínáme 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ý klíč 2.

1 assertEq(toUint256(_callOutput,0), VAL_A);
2 assertEq(toUint256(_callOutput,32), VAL_B);
3 assertEq(toUint256(_callOutput,64), VAL_C);
4 assertEq(toUint256(_callOutput,96), VAL_C);

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

1 // Druhé volání, můžeme použít cache
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
4
5 // První hodnota v cache
6 bytes1(0x01),

Klíče cache pod 16 jsou pouze jeden bajt.

1 // Druhá hodnota, nepřidávejte ji do cache
2 cache.DONT_CACHE(),
3 bytes32(VAL_B),
4
5 // Třetí a čtvrtá hodnota, stejná hodnota
6 bytes1(0x02),
7 bytes1(0x02)
8 );
9 .
10 .
11 .
12 } // testReadParam
Zobrazit vše

Testy po volání jsou totožné s testy po prvním volání.

1 function testEncodeVal() public {

Tato funkce je podobná funkci testReadParam, s výjimkou toho, že místo explicitního psaní parametrů používáme encodeVal().

1 .
2 .
3 .
4 _callInput = bytes.concat(
5 FOUR_PARAMS,
6 cache.encodeVal(VAL_A),
7 cache.encodeVal(VAL_B),
8 cache.encodeVal(VAL_C),
9 cache.encodeVal(VAL_D)
10 );
11 .
12 .
13 .
14 assertEq(_callInput.length, 4+1*4);
15 } // testEncodeVal
Zobrazit vše

Jediný dodatečný test 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.

1 // Testujte encodeVal, když je klíč delší než jeden bajt
2 // Maximálně tři bajty, protože plnění cache na čtyři bajty trvá
3 // příliš dlouho.
4 function testEncodeValBig() public {
5 // Vložte několik hodnot do cache.
6 // Pro zjednodušení použijte klíč n pro hodnotu n.
7 for(uint i=1; i<0x1FFF; i++) {
8 cache.cacheWrite(i);
9 }
Zobrazit vše

Výše uvedená funkce testEncodeVal zapisuje do cache pouze čtyři hodnoty, takže část funkce, která se zabývá vícebajtovými hodnotamiopens in a new tab se nekontroluje. Ale tento kód je složitý a náchylný k chybám.

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

1 .
2 .
3 .
4
5 _callInput = bytes.concat(
6 FOUR_PARAMS,
7 cache.encodeVal(0x000F), // Jeden bajt 0x0F
8 cache.encodeVal(0x0010), // Dva bajty 0x1010
9 cache.encodeVal(0x0100), // Dva bajty 0x1100
10 cache.encodeVal(0x1000) // Tři bajty 0x201000
11 );
Zobrazit vše

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).

1 .
2 .
3 .
4 .
5 } // testEncodeValBig
6
7
8 // Otestujte, co se stane s příliš malým bufferem, dostaneme revert
9 function testShortCalldata() public {
Zobrazit vše

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

1 .
2 .
3 .
4 (_success, _callOutput) = _cacheAddr.call(_callInput);
5 assertEq(_success, false);
6 } // testShortCalldata

Jelikož se to vrací, výsledek, který bychom měli dostat, je false.

1 // Volání s klíči cache, které tam nejsou
2 function testNoCacheKey() public {
3 .
4 .
5 .
6 _callInput = bytes.concat(
7 FOUR_PARAMS,
8
9 // První hodnota, přidejte ji do cache
10 cache.INTO_CACHE(),
11 bytes32(VAL_A),
12
13 // Druhá hodnota
14 bytes1(0x0F),
15 bytes2(0x1234),
16 bytes11(0xA10102030405060708090A)
17 );
Zobrazit vše

Tato funkce dostane čtyři naprosto legitimní parametry, s výjimkou toho, že cache je prázdná, takže tam nejsou žádné hodnoty ke čtení.

1 .
2 .
3 .
4 // Otestujte, co s příliš dlouhým bufferem, vše funguje
5 function testLongCalldata() public {
6 address _cacheAddr = address(cache);
7 bool _success;
8 bytes memory _callInput;
9 bytes memory _callOutput;
10
11 // První volání, cache je prázdná
12 _callInput = bytes.concat(
13 FOUR_PARAMS,
14
15 // První hodnota, přidejte ji do cache
16 cache.INTO_CACHE(), bytes32(VAL_A),
17
18 // Druhá hodnota, přidejte ji do cache
19 cache.INTO_CACHE(), bytes32(VAL_B),
20
21 // Třetí hodnota, přidejte ji do cache
22 cache.INTO_CACHE(), bytes32(VAL_C),
23
24 // Čtvrtá hodnota, přidejte ji do cache
25 cache.INTO_CACHE(), bytes32(VAL_D),
26
27 // A další hodnota pro „štěstí“
28 bytes4(0x31112233)
29 );
Zobrazit vše

Tato funkce posílá pět hodnot. Víme, že pátá hodnota je ignorována, protože se nejedná o platný záznam cache, což by způsobilo vrácení, kdyby nebyla zahrnuta.

1 (_success, _callOutput) = _cacheAddr.call(_callInput);
2 assertEq(_success, true);
3 .
4 .
5 .
6 } // testLongCalldata
7
8} // CacheTest
9
Zobrazit vše

Ukázková aplikace

Psaní testů v Solidity je sice skvělé, ale aby byla dapp užitečná, musí být schopna zpracovávat požadavky i mimo blockchain. 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, dostanete revert.

Kontrakt

Zde je kontraktopens in a new tab. Většinou se opakuje to, co jsme již udělali s Cache a CacheTest, takže se budeme zabývat pouze zajímavými částmi.

1import "./Cache.sol";
2
3contract WORM is Cache {

Nejjednodušší způsob, jak použít Cache, je zdědit ho ve vlastním kontraktu.

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

Tato funkce je podobná fourParam ve výše uvedeném CacheTest. Protože se nedržíme specifikací ABI, je nejlepší do funkce nedeklarovat žádné parametry.

1 // Usnadněte si volání
2 // Podpis funkce pro writeEntryCached(), s laskavým svolením
3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
4 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;

Externí kód, který volá writeEntryCached, bude muset manuálně sestavit calldata, namísto použití worm.writeEntryCached, protože nedodržujeme specifikace ABI. Tato konstantní hodnota pouze 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 getter funkci, worm.WRITE_ENTRY_CACHED().

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

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

Testovací kód

Zde je testovací kód pro kontraktopens in a new tab. Opět se podívejme pouze na to, co je zajímavé.

1 function testWReadWrite() public {
2 worm.writeEntry(0xDEAD, 0x60A7);
3
4 vm.expectRevert(bytes("entry already written"));
5 worm.writeEntry(0xDEAD, 0xBEEF);

Tímto (vm.expectRevert)opens in a new tab v testu Foundry specifikujeme, že další volání by mělo selhat, a uvádíme důvod selhání. To platí, když používáme syntaxi <kontrakt>.<název funkce>() spíše než vytváření calldata a volání kontraktu pomocí nízkoúrovňového rozhraní (<kontrakt>.call() atd.).

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

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

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

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

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

Protož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í.

1 event EntryWritten(uint indexed key, uint indexed value);
2
3 .
4 .
5 .
6
7 _callInput = bytes.concat(
8 worm.WRITE_ENTRY_CACHED(), worm.encodeVal(a), worm.encodeVal(b));
9 vm.expectEmit(true, true, false, false);
10 emit EntryWritten(a, b);
11 (_success,) = address(worm).call(_callInput);
Zobrazit vše

Toto je způsob, jak ve Foundry ověřit, že kód správně emituje událostopens in a new tab.

Klient

Jedna věc, kterou se testy v Solidity nezískáte, je JavaScriptový kód, který můžete zkopírovat a vložit do své vlastní aplikace. Abych mohl napsat tento kód, nasadil jsem WORM na Optimism Goerliopens in a new tab, nový testnet Optimismuopens in a new tab. Je na adrese 0xd34335b1d818cee54e3323d3246bd31d94e6a78aopens in a new tab.

Zde si můžete prohlédnout JavaScriptový kód pro klientaopens in a new tab. Použití:

  1. Klonujte git repozitář:

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

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

    1cp .env.example .env
  4. Upravte .env pro vaši konfiguraci:

    ParametrHodnota
    MNEMONICMnemotechnická pomůcka pro účet, který má dostatek ETH na zaplacení transakce. Zde můžete získat ETH zdarma pro síť Optimism Goerliopens in a new tab.
    OPTIMISM_GOERLI_URLURL k Optimism Goerli. Veřejný koncový bod https://goerli.optimism.io má omezenou rychlost, ale pro naše potřeby je dostačující.
  5. Spusťte index.js.

    1node index.js

    Tato ukázková aplikace nejprve zapíše položku do WORM, zobrazí calldata a odkaz na transakci na Etherscanu. Poté přečte zpět tuto položku a zobrazí klíč, který používá, a hodnoty v položce (hodnota, číslo bloku a autor).

Většina klienta je normální Dapp JavaScript. Takže opět projdeme jen zajímavé části.

1.
2.
3.
4const main = async () => {
5 const func = await worm.WRITE_ENTRY_CACHED()
6
7 // Pokaždé je potřeba nový klíč
8 const key = await worm.encodeVal(Number(new Date()))

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

1const val = await worm.encodeVal("0x600D")
2
3// Zapište položku
4const calldata = func + key.slice(2) + val.slice(2)

Ethers očekává, že data volání budou hexadecimální řetězec, 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.

1const tx = await worm.populateTransaction.writeEntryCached()
2tx.data = calldata
3
4sentTx = await wallet.sendTransaction(tx)

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

1 .
2 .
3 .
4 // Přečtěte právě zapsanou položku
5 const realKey = '0x' + key.slice(4) // odstraňte příznak FF
6 const entryRead = await worm.readEntry(realKey)
7 .
8 .
9 .
Zobrazit vše

Pro čtení položek můžeme použít normální mechanismus. U funkcí view není třeba používat cachování parametrů.

Závěr

Kód v tomto článku je proof of concept, jehož účelem je usnadnit pochopení myšlenky. Pro produkční systém byste mohli chtít implementovat některé další funkce:

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

  • Místo globální cache možná mít 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. Mohlo by mít smysl mít samostatnou cache pouze pro adresy.

  • V současné době jsou klíče cache založeny na algoritmu „kdo dřív přijde, ten 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 vést počítadla použití záznamů cache a reorganizovat je tak, aby šestnáct nejběžnějších hodnot bylo jednobajtových, dalších 4080 nejběžnějších hodnot dvoubajtových atd.

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

    1. Noam Naive zavolá encodeVal k zakódování adresy, na kterou chce poslat tokeny. Tato adresa je jedna z prvních použitých v aplikaci, takže zakódovaná hodnota je 0x06. Toto je funkce view, ne transakce, takže je to mezi Noamem a uzlem, který používá, a nikdo jiný o tom neví.

    2. Owen Owner spustí operaci přeuspořádání cache. Velmi málo lidí skutečně používá tuto adresu, takže je nyní zakódována jako 0x201122. Jiná hodnota, 1018, je přiřazena 0x06.

    3. Noam Naive posílá své tokeny na 0x06. Dostanou se na adresu 0x0000000000000000000000000de0b6b3a7640000, a protože nikdo nezná soukromý klíč k této adrese, jsou tam prostě zaseknuté. Noam není spokojený.

    Existují způsoby, jak tento problém vyřešit, a související problém transakcí, které jsou v mempoolu během přeuspořádání cache, ale musíte si toho být vědomi.

Cachování jsem zde demonstroval na Optimismu, protože jsem zaměstnancem Optimismu a je to 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 v porovnání s tím je zápis transakčních dat na L1 hlavním nákladem.

Více z mé práce najdete zdeopens in a new tab.

Stránka naposledy aktualizována: 25. února 2026

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