Vše, co můžete cachovat
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:
-
Pokud je první bajt
0xFF, vezměte následujících 32 bajtů jako hodnotu parametru a zapište ji do cache. -
Pokud je první bajt
0xFE, vezměte následujících 32 bajtů jako hodnotu parametru, ale nezapisujte ji do cache. -
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 calldata Klíč cache 0x0F 0x0F 0x10,0x10 0x10 0x12,0xAC 0x02AC 0x2D,0xEA, 0xD6 0x0DEAD6
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: UNLICENSED2pragma solidity ^0.8.13;345contract Cache {67 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é.
12 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 zachovat2 // 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 } // cacheReadPřečte hodnotu z cache.
1 // Zapíše hodnotu do cache, pokud tam ještě není2 // Veřejné jen proto,aby fungoval test3 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 cache2 // obsahovat, je 0x0D následovaný 15x 0xFF. Pokud už je délka cache tak3 // velká, selže.4 // 1 2 3 4 5 6 7 8 9 A B C D E F5 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íče2 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 } // cacheWriteVrá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;34 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 } // _calldataVal345 // Načte jeden parametr z calldata, počínaje od _fromByte6 function _readParam(uint _fromByte) internal7 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 zbytek2 uint8 _firstByte;34 _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í.
12 // Přečte hodnotu, ale nezapíše ji do cache3 if (_firstByte == uint8(DONT_CACHE))4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));56 // Přečte hodnotu a zapíše ji do cache7 if (_firstByte == uint8(INTO_CACHE)) {8 uint _param = _calldataVal(_fromByte+1, 32);9 cacheWrite(_param);10 return(_fromByte+33, _param);11 }1213 // Pokud jsme se dostali sem, znamená to, že musíme číst z cache1415 // Počet bajtů navíc ke čtení16 uint8 _extraBytes = _firstByte / 16;Zobrazit všeVezmě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);34 return (_fromByte+_extraBytes+1, cacheRead(_key));56 } // _readParam789 // Načte n parametrů (funkce vědí, kolik parametrů očekávají)10 function _readParams(uint _paramNum) internal returns (uint[] memory) {Zobrazit všePoč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é čteme2 uint[] memory params = new uint[](_paramNum);34 // Parametry začínají na 4. bajtu, předtím je podpis funkce5 uint _atByte = 4;67 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.
12 return(params);3 } // readParams45 // Pro testování _readParams, testování čtení čtyř parametrů6 function fourParam() public7 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 } // fourParamZobrazit všeJednou 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];23 // Hodnota ještě není v cache, přidejte ji4 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 bajt2 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 0x1vvv2 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 a3 // jednoduchost.45 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šeOstatní 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 } // encodeVal23} // CacheTestová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: UNLICENSED2pragma solidity ^0.8.13;34import "forge-std/Test.sol";567// 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;34 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 }45 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 } // testCaching345 // Cachovat stejnou hodnotu vícekrát, zajistit, aby klíč zůstal6 // 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šeNejprve 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 } // testRepeatCachingTeoreticky 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 pure4 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;45 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.
12 return tempUint;3 } // toUint25645 // Podpis funkce pro fourParams(), s laskavým svolením6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;89 // Jen několik konstantních hodnot, abychom viděli, že dostáváme zpět správné hodnoty10 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šeNě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 cache2 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 cache2 _callInput = bytes.concat(3 FOUR_PARAMS,45 // První hodnota v cache6 bytes1(0x01),Klíče cache pod 16 jsou pouze jeden bajt.
1 // Druhá hodnota, nepřidávejte ji do cache2 cache.DONT_CACHE(),3 bytes32(VAL_B),45 // Třetí a čtvrtá hodnota, stejná hodnota6 bytes1(0x02),7 bytes1(0x02)8 );9 .10 .11 .12 } // testReadParamZobrazit všeTesty 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 } // testEncodeValZobrazit všeJediný 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 bajt2 // 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šeVýš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 .45 _callInput = bytes.concat(6 FOUR_PARAMS,7 cache.encodeVal(0x000F), // Jeden bajt 0x0F8 cache.encodeVal(0x0010), // Dva bajty 0x10109 cache.encodeVal(0x0100), // Dva bajty 0x110010 cache.encodeVal(0x1000) // Tři bajty 0x20100011 );Zobrazit všeOtestujte 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 } // testEncodeValBig678 // Otestujte, co se stane s příliš malým bufferem, dostaneme revert9 function testShortCalldata() public {Zobrazit všeOtestujte, 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 } // testShortCalldataJelikož se to vrací, výsledek, který bychom měli dostat, je false.
1 // Volání s klíči cache, které tam nejsou2 function testNoCacheKey() public {3 .4 .5 .6 _callInput = bytes.concat(7 FOUR_PARAMS,89 // První hodnota, přidejte ji do cache10 cache.INTO_CACHE(),11 bytes32(VAL_A),1213 // Druhá hodnota14 bytes1(0x0F),15 bytes2(0x1234),16 bytes11(0xA10102030405060708090A)17 );Zobrazit všeTato 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 funguje5 function testLongCalldata() public {6 address _cacheAddr = address(cache);7 bool _success;8 bytes memory _callInput;9 bytes memory _callOutput;1011 // První volání, cache je prázdná12 _callInput = bytes.concat(13 FOUR_PARAMS,1415 // První hodnota, přidejte ji do cache16 cache.INTO_CACHE(), bytes32(VAL_A),1718 // Druhá hodnota, přidejte ji do cache19 cache.INTO_CACHE(), bytes32(VAL_B),2021 // Třetí hodnota, přidejte ji do cache22 cache.INTO_CACHE(), bytes32(VAL_C),2324 // Čtvrtá hodnota, přidejte ji do cache25 cache.INTO_CACHE(), bytes32(VAL_D),2627 // A další hodnota pro „štěstí“28 bytes4(0x31112233)29 );Zobrazit všeTato 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 } // testLongCalldata78} // CacheTest9Zobrazit všeUká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";23contract 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 } // writeEntryCachedTato 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ím3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d34 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 view2 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);34 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);23 .4 .5 .67 _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šeToto 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í:
-
Klonujte git repozitář:
1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git -
Nainstalujte potřebné balíčky:
1cd javascript2yarn -
Zkopírujte konfigurační soubor:
1cp .env.example .env -
Upravte
.envpro vaši konfiguraci:Parametr Hodnota MNEMONIC Mnemotechnická 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_URL URL k Optimism Goerli. Veřejný koncový bod https://goerli.optimism.iomá omezenou rychlost, ale pro naše potřeby je dostačující. -
Spusťte
index.js.1node index.jsTato 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()67 // 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")23// Zapište položku4const 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 = calldata34sentTx = 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žku5 const realKey = '0x' + key.slice(4) // odstraňte příznak FF6 const entryRead = await worm.readEntry(realKey)7 .8 .9 .Zobrazit všePro č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í:
-
Noam Naive zavolá
encodeValk 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 funkceview, ne transakce, takže je to mezi Noamem a uzlem, který používá, a nikdo jiný o tom neví. -
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.
-
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