Wszystko, co możesz zbuforować
Podczas korzystania z pakietów zbiorczych koszt jednego bajtu w transakcji jest znacznie wyższy niż koszt slotu pamięci. Dlatego sensowne jest buforowanie jak największej ilości informacji w łańcuchu.
W tym artykule dowiesz się, jak stworzyć i używać kontraktu buforującego w taki sposób, aby każda wartość parametru, która prawdopodobnie będzie używana wielokrotnie, została zbuforowana i była dostępna do użycia (po pierwszym razie) przy użyciu znacznie mniejszej liczby bajtów, oraz jak napisać kod poza łańcuchem, który korzysta z tej pamięci podręcznej.
Jeśli chcesz pominąć artykuł i po prostu zobaczyć kod źródłowy, jest on tutaj (opens in a new tab). Stos programistyczny to Foundry (opens in a new tab).
Ogólny projekt
Dla uproszczenia założymy, że wszystkie parametry transakcji to uint256 o długości 32 bajtów. Gdy otrzymamy transakcję, będziemy analizować każdy parametr w następujący sposób:
-
Jeśli pierwszy bajt to
0xFF, weź następne 32 bajty jako wartość parametru i zapisz ją w pamięci podręcznej. -
Jeśli pierwszy bajt to
0xFE, weź następne 32 bajty jako wartość parametru, ale nie zapisuj jej w pamięci podręcznej. -
Dla każdej innej wartości weź cztery górne bity jako liczbę dodatkowych bajtów, a dolne cztery bity jako najbardziej znaczące bity klucza pamięci podręcznej. Oto kilka przykładów:
Bajty w calldata Klucz pamięci podręcznej 0x0F 0x0F 0x10,0x10 0x10 0x12,0xAC 0x02AC 0x2D,0xEA, 0xD6 0x0DEAD6
Manipulacja pamięcią podręczną
Pamięć podręczna jest zaimplementowana w Cache.sol (opens in a new tab). Przejdźmy przez niego linia po linii.
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;Te stałe służą do interpretacji specjalnych przypadków, w których podajemy wszystkie informacje i chcemy je zapisać w pamięci podręcznej lub nie. Zapisywanie do pamięci podręcznej wymaga dwóch operacji SSTORE (opens in a new tab) w wcześniej nieużywanych slotach pamięci kosztem 22100 jednostek gazu każda, dlatego jest to opcjonalne.
12 mapping(uint => uint) public val2key;Odwzorowanie (opens in a new tab) pomiędzy wartościami a ich kluczami. Ta informacja jest niezbędna do zakodowania wartości przed wysłaniem transakcji.
1 // Lokalizacja n ma wartość dla klucza n+1, ponieważ musimy zachować2 // zero jako "nie w pamięci podręcznej".3 uint[] public key2val;Możemy użyć tablicy do mapowania kluczy na wartości, ponieważ to my przypisujemy klucze i dla uproszczenia robimy to sekwencyjnie.
1 function cacheRead(uint _key) public view returns (uint) {2 require(_key <= key2val.length, "Odczyt niezainicjowanego wpisu w pamięci podręcznej");3 return key2val[_key-1];4 } // cacheReadOdczytaj wartość z pamięci podręcznej.
1 // Zapisz wartość w pamięci podręcznej, jeśli jeszcze jej tam nie ma2 // Funkcja publiczna tylko po to, aby umożliwić działanie testu3 function cacheWrite(uint _value) public returns (uint) {4 // Jeśli wartość jest już w pamięci podręcznej, zwróć bieżący klucz5 if (val2key[_value] != 0) {6 return val2key[_value];7 }Nie ma sensu umieszczać tej samej wartości w pamięci podręcznej więcej niż raz. Jeśli wartość już istnieje, po prostu zwróć istniejący klucz.
1 // Ponieważ 0xFE jest przypadkiem specjalnym, największy klucz, jaki może pomieścić pamięć podręczna, to2 // 0x0D, po którym następuje 15 wartości 0xFF. Jeśli długość pamięci podręcznej jest już tak3 // duża, zakończ błędem.4 // 1 2 3 4 5 6 7 8 9 A B C D E F5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,6 "przepełnienie pamięci podręcznej");Nie sądzę, abyśmy kiedykolwiek uzyskali tak dużą pamięć podręczną (około 1,8*1037 wpisów, co wymagałoby około 1027 TB do przechowywania). Jednak jestem na tyle stary, że pamiętam "640 kB powinno zawsze wystarczyć" (opens in a new tab). Ten test jest bardzo tani.
1 // Zapisz wartość, używając następnego klucza2 val2key[_value] = key2val.length+1;Dodaj wyszukiwanie wsteczne (od wartości do klucza).
1 key2val.push(_value);Dodaj wyszukiwanie w przód (od klucza do wartości). Ponieważ przypisujemy wartości sekwencyjnie, możemy po prostu dodać ją po ostatniej wartości w tablicy.
1 return key2val.length;2 } // cacheWriteZwróć nową długość key2val, która jest komórką, w której przechowywana jest nowa wartość.
1 function _calldataVal(uint startByte, uint length)2 private pure returns (uint)Ta funkcja odczytuje wartość z calldata o dowolnej długości (do 32 bajtów, czyli rozmiaru słowa).
1 {2 uint _retVal;34 require(length < 0x21,5 "Limit długości _calldataVal to 32 bajty");6 require(length + startByte <= msg.data.length,7 "_calldataVal próbuje odczytać poza calldatasize");Ta funkcja jest wewnętrzna, więc jeśli reszta kodu jest napisana poprawnie, te testy nie są wymagane. Jednak nie kosztują wiele, więc równie dobrze możemy je mieć.
1 assembly {2 _retVal := calldataload(startByte)3 }Ten kod jest w języku Yul (opens in a new tab). Odczytuje 32-bajtową wartość z calldata. Działa to nawet wtedy, gdy calldata kończy się przed startByte+32, ponieważ niezainicjowana przestrzeń w EVM jest uważana za zerową.
1 _retVal = _retVal >> (256-length*8);Niekoniecznie chcemy wartości 32-bajtowej. To usuwa nadmiarowe bajty.
1 return _retVal;2 } // _calldataVal345 // Odczytaj pojedynczy parametr z calldata, zaczynając od _fromByte6 function _readParam(uint _fromByte) internal7 returns (uint _nextByte, uint _parameterValue)8 {Odczytaj pojedynczy parametr z calldata. Zauważ, że musimy zwrócić nie tylko odczytaną wartość, ale także lokalizację następnego bajtu, ponieważ parametry mogą mieć długość od 1 do 33 bajtów.
1 // Pierwszy bajt mówi nam, jak interpretować resztę2 uint8 _firstByte;34 _firstByte = uint8(_calldataVal(_fromByte, 1));Solidity próbuje zredukować liczbę błędów, zabraniając potencjalnie niebezpiecznych niejawnych konwersji typów (opens in a new tab). Degradacja, na przykład z 256 bitów do 8 bitów, musi być jawna.
12 // Odczytaj wartość, ale nie zapisuj jej w pamięci podręcznej3 if (_firstByte == uint8(DONT_CACHE))4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));56 // Odczytaj wartość i zapisz ją w pamięci podręcznej7 if (_firstByte == uint8(INTO_CACHE)) {8 uint _param = _calldataVal(_fromByte+1, 32);9 cacheWrite(_param);10 return(_fromByte+33, _param);11 }1213 // Jeśli dotarliśmy tutaj, oznacza to, że musimy odczytać z pamięci podręcznej1415 // Liczba dodatkowych bajtów do odczytania16 uint8 _extraBytes = _firstByte / 16;Pokaż wszystkoWeź dolny półbajt (nibble) (opens in a new tab) i połącz go z pozostałymi bajtami, aby odczytać wartość z pamięci podręcznej.
1 uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +2 _calldataVal(_fromByte+1, _extraBytes);34 return (_fromByte+_extraBytes+1, cacheRead(_key));56 } // _readParam789 // Odczytaj n parametrów (funkcje wiedzą, ilu parametrów oczekują)10 function _readParams(uint _paramNum) internal returns (uint[] memory) {Pokaż wszystkoMoglibyśmy pobrać liczbę parametrów z samych danych wywołania (calldata), ale funkcje, które nas wywołują, wiedzą, ilu parametrów oczekują. Łatwiej jest pozwolić, aby to one nam o tym powiedziały.
1 // Parametry, które odczytujemy2 uint[] memory params = new uint[](_paramNum);34 // Parametry zaczynają się od 4 bajtu, przed nim jest sygnatura funkcji5 uint _atByte = 4;67 for(uint i=0; i<_paramNum; i++) {8 (_atByte, params[i]) = _readParam(_atByte);9 }Pokaż wszystkoOdczytuj parametry, aż uzyskasz potrzebną liczbę. Jeśli wykroczymy poza koniec calldata, _readParams cofnie wywołanie.
12 return(params);3 } // readParams45 // Do testowania _readParams, przetestuj odczyt czterech parametrów6 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 } // fourParamPokaż wszystkoJedną z wielkich zalet Foundry jest to, że pozwala na pisanie testów w Solidity (zobacz Testowanie pamięci podręcznej poniżej). To znacznie ułatwia testy jednostkowe. Jest to funkcja, która odczytuje cztery parametry i zwraca je, aby test mógł zweryfikować ich poprawność.
1 // Pobierz wartość, zwróć bajty, które ją zakodują (używając pamięci podręcznej, jeśli to możliwe)2 function encodeVal(uint _val) public view returns(bytes memory) {encodeVal to funkcja, którą kod offchain (poza łańcuchem) wywołuje, aby pomóc w tworzeniu calldata korzystających z pamięci podręcznej. Otrzymuje pojedynczą wartość i zwraca bajty, które ją kodują. Ta funkcja jest funkcją typu view, więc nie wymaga transakcji, a wywołana z zewnątrz nie zużywa gazu.
1 uint _key = val2key[_val];23 // Wartości nie ma jeszcze w pamięci podręcznej, więc dodaj ją4 if (_key == 0)5 return bytes.concat(INTO_CACHE, bytes32(_val));W EVM cała niezainicjowana pamięć masowa jest traktowana jako zera. Więc jeśli szukamy klucza dla wartości, której tam nie ma, otrzymujemy zero. W takim przypadku bajty, które ją kodują, to INTO_CACHE (więc zostanie zbuforowana następnym razem), a po nich rzeczywista wartość.
1 // Jeśli klucz jest <0x10, zwróć go jako pojedynczy bajt2 if (_key < 0x10)3 return bytes.concat(bytes1(uint8(_key)));Pojedyncze bajty są najłatwiejsze. Używamy po prostu bytes.concat (opens in a new tab), aby zamienić typ bytes<n> na tablicę bajtów o dowolnej długości. Pomimo nazwy działa dobrze, gdy jest zaopatrzona w tylko jeden argument.
1 // Wartość dwubajtowa, zakodowana jako 0x1vvv2 if (_key < 0x1000)3 return bytes.concat(bytes2(uint16(_key) | 0x1000));Gdy mamy klucz mniejszy niż 163, możemy go wyrazić w dwóch bajtach. Najpierw konwertujemy _key, która jest wartością 256-bitową, na wartość 16-bitową i używamy logicznego LUB, aby dodać liczbę dodatkowych bajtów do pierwszego bajtu. Następnie zamieniamy ją na wartość bytes2, którą można przekonwertować na bytes.
1 // Prawdopodobnie istnieje sprytny sposób na wykonanie następujących linii jako pętli,2 // ale jest to funkcja widoku, więc optymalizuję pod kątem czasu programisty i3 // prostoty.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)));Pokaż wszystkoPozostałe wartości (3 bajty, 4 bajty itp.) są obsługiwane w ten sam sposób, tylko z różnymi rozmiarami pól.
1 // Jeśli dotarliśmy tutaj, coś jest nie tak.2 revert("Błąd w encodeVal, nie powinno się zdarzyć");Jeśli tu dotrzemy, oznacza to, że otrzymaliśmy klucz, który nie jest mniejszy niż 16*25615. Ale cacheWrite ogranicza klucze, więc nie możemy nawet dojść do 14*25616 (co miałoby pierwszy bajt 0xFE, więc wyglądałoby jak DONT_CACHE). Ale niewiele nas kosztuje dodanie testu na wypadek, gdyby przyszły programista wprowadził błąd.
1 } // encodeVal23} // CacheTestowanie pamięci podręcznej
Jedną z zalet Foundry jest to, że pozwala pisać testy w Solidity (opens in a new tab), co ułatwia pisanie testów jednostkowych. Testy dla klasy Cache są tutaj (opens in a new tab). Ponieważ kod testowy jest powtarzalny, jak to zwykle bywa z testami, w tym artykule wyjaśniono tylko interesujące części.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.13;34import "forge-std/Test.sol";567// Należy uruchomić `forge test -vv` dla konsoli.8import "forge-std/console.sol";To tylko standardowy kod, który jest niezbędny do użycia pakietu testowego i console.log.
1import "src/Cache.sol";Musimy znać kontrakt, który testujemy.
1contract CacheTest is Test {2 Cache cache;34 function setUp() public {5 cache = new Cache();6 }Funkcja setUp jest wywoływana przed każdym testem. W tym przypadku po prostu tworzymy nową pamięć podręczną, aby nasze testy nie wpływały na siebie nawzajem.
1 function testCaching() public {Testy to funkcje, których nazwy zaczynają się od test. Ta funkcja sprawdza podstawową funkcjonalność pamięci podręcznej, zapisując wartości i ponownie je odczytując.
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);W ten sposób przeprowadza się rzeczywiste testowanie za pomocą funkcji assert... (opens in a new tab). W tym przypadku sprawdzamy, czy wartość, którą zapisaliśmy, jest tą samą, którą odczytaliśmy. Możemy zignorować wynik cache.cacheWrite, ponieważ wiemy, że klucze pamięci podręcznej są przypisywane liniowo.
1 }2 } // testCaching345 // Zapisz tę samą wartość wiele razy, upewnij się, że klucz pozostaje6 // taki sam7 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 }Pokaż wszystkoNajpierw zapisujemy każdą wartość do pamięci podręcznej dwukrotnie i upewniamy się, że klucze są takie same (co oznacza, że drugi zapis tak naprawdę nie miał miejsca).
1 for(uint i=1; i<100; i+=3) {2 uint _key = cache.cacheWrite(i);3 assertEq(_key, i);4 }5 } // testRepeatCachingTeoretycznie może istnieć błąd, który nie wpływa na kolejne zapisy w pamięci podręcznej. Dlatego tutaj wykonujemy kilka zapisów, które nie są następujące po sobie i widzimy, że wartości nadal nie są nadpisywane.
1 // Odczytaj uint z bufora pamięci (aby upewnić się, że otrzymamy z powrotem parametry,2 // które wysłaliśmy)3 function toUint256(bytes memory _bytes, uint256 _start) internal pure4 returns (uint256)Odczytaj 256-bitowe słowo z bufora bytes memory. Ta funkcja narzędziowa pozwala nam zweryfikować, czy otrzymujemy poprawne wyniki, gdy uruchamiamy wywołanie funkcji, która używa pamięci podręcznej.
1 {2 require(_bytes.length >= _start + 32, "toUint256_outOfBounds");3 uint256 tempUint;45 assembly {6 tempUint := mload(add(add(_bytes, 0x20), _start))7 }Yul nie obsługuje struktur danych poza uint256, więc gdy odwołujesz się do bardziej zaawansowanej struktury danych, takiej jak bufor pamięci _bytes, otrzymujesz adres tej struktury. Solidity przechowuje wartości bytes memory jako 32-bajtowe słowo, które zawiera długość, po której następują rzeczywiste bajty, więc aby uzyskać bajt o numerze _start, musimy obliczyć _bytes+32+_start.
12 return tempUint;3 } // toUint25645 // Sygnatura funkcji dla fourParams(), dzięki uprzejmości6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;89 // Po prostu kilka stałych wartości, aby zobaczyć, czy otrzymujemy poprawne wartości z powrotem10 uint256 constant VAL_A = 0xDEAD60A7;11 uint256 constant VAL_B = 0xBEEF;12 uint256 constant VAL_C = 0x600D;13 uint256 constant VAL_D = 0x600D60A7;Pokaż wszystkoKilka stałych, których potrzebujemy do testowania.
1 function testReadParam() public {Wywołaj fourParams(), funkcję, która używa readParams, aby przetestować, czy potrafimy poprawnie odczytać parametry.
1 address _cacheAddr = address(cache);2 bool _success;3 bytes memory _callInput;4 bytes memory _callOutput;Nie możemy użyć normalnego mechanizmu ABI do wywołania funkcji przy użyciu pamięci podręcznej, więc musimy użyć niskopoziomowego mechanizmu <address>.call() (opens in a new tab). Mechanizm ten przyjmuje na wejściu bytes memory, a na wyjściu zwraca to samo (oraz wartość logiczną).
1 // Pierwsze wywołanie, pamięć podręczna jest pusta2 _callInput = bytes.concat(3 FOUR_PARAMS,Użyteczne jest, aby ten sam kontrakt obsługiwał zarówno funkcje buforowane (dla wywołań bezpośrednio z transakcji), jak i niebuforowane (dla wywołań z innych inteligentnych kontraktów). Aby to zrobić, musimy nadal polegać na mechanizmie Solidity do wywoływania poprawnej funkcji, zamiast umieszczać wszystko w funkcji fallback (opens in a new tab). Dzięki temu kompozycyjność staje się o wiele łatwiejsza. Pojedynczy bajt w większości przypadków wystarczyłby do zidentyfikowania funkcji, więc marnujemy trzy bajty (16*3=48 jednostek gazu). Jednak w chwili, gdy to piszę, te 48 jednostek gazu kosztuje 0,07 centa, co jest rozsądnym kosztem prostszego, mniej podatnego na błędy kodu.
1 // Pierwsza wartość, dodaj ją do pamięci podręcznej2 cache.INTO_CACHE(),3 bytes32(VAL_A),Pierwsza wartość: flaga informująca, że jest to pełna wartość, która musi zostać zapisana w pamięci podręcznej, a następnie 32 bajty wartości. Pozostałe trzy wartości są podobne, z wyjątkiem tego, że VAL_B nie jest zapisywana do pamięci podręcznej, a VAL_C jest zarówno trzecim, jak i czwartym parametrem.
1 .2 .3 .4 );5 (_success, _callOutput) = _cacheAddr.call(_callInput);To tutaj faktycznie wywołujemy kontrakt Cache.
1 assertEq(_success, true);Oczekujemy, że wywołanie się powiedzie.
1 assertEq(cache.cacheRead(1), VAL_A);2 assertEq(cache.cacheRead(2), VAL_C);Zaczynamy z pustą pamięcią podręczną, a następnie dodajemy VAL_A, a po niej VAL_C. Spodziewalibyśmy się, że pierwszy będzie miał klucz 1, a drugi 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);Dane wyjściowe to cztery parametry. Tutaj weryfikujemy, czy są poprawne.
1 // Drugie wywołanie, możemy użyć pamięci podręcznej2 _callInput = bytes.concat(3 FOUR_PARAMS,45 // Pierwsza wartość w pamięci podręcznej6 bytes1(0x01),Klucze pamięci podręcznej poniżej 16 to tylko jeden bajt.
1 // Druga wartość, nie dodawaj jej do pamięci podręcznej2 cache.DONT_CACHE(),3 bytes32(VAL_B),45 // Trzecia i czwarta wartość, ta sama wartość6 bytes1(0x02),7 bytes1(0x02)8 );9 .10 .11 .12 } // testReadParamPokaż wszystkoTesty po wywołaniu są identyczne jak te po pierwszym wywołaniu.
1 function testEncodeVal() public {Ta funkcja jest podobna do testReadParam, z wyjątkiem tego, że zamiast jawnie pisać parametry, używamy 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 } // testEncodeValPokaż wszystkoJedynym dodatkowym testem w testEncodeVal() jest weryfikacja, czy długość _callInput jest prawidłowa. Dla pierwszego wywołania jest to 4+33*4. Dla drugiego, gdzie każda wartość jest już w pamięci podręcznej, wynosi ona 4+1*4.
1 // Przetestuj encodeVal, gdy klucz ma więcej niż jeden bajt2 // Maksymalnie trzy bajty, ponieważ wypełnienie pamięci podręcznej do czterech bajtów trwa3 // zbyt długo.4 function testEncodeValBig() public {5 // Umieść pewną liczbę wartości w pamięci podręcznej.6 // Aby uprościć sprawę, użyj klucza n dla wartości n.7 for(uint i=1; i<0x1FFF; i++) {8 cache.cacheWrite(i);9 }Pokaż wszystkoPowyższa funkcja testEncodeVal zapisuje tylko cztery wartości do pamięci podręcznej, więc część funkcji, która zajmuje się wartościami wielobajtowymi (opens in a new tab), nie jest sprawdzana. Ale ten kod jest skomplikowany i podatny na błędy.
Pierwsza część tej funkcji to pętla, która po kolei zapisuje do pamięci podręcznej wszystkie wartości od 1 do 0x1FFF, dzięki czemu będziemy w stanie zakodować te wartości i wiedzieć, dokąd trafią.
1 .2 .3 .45 _callInput = bytes.concat(6 FOUR_PARAMS,7 cache.encodeVal(0x000F), // Jeden bajt 0x0F8 cache.encodeVal(0x0010), // Dwa bajty 0x10109 cache.encodeVal(0x0100), // Dwa bajty 0x110010 cache.encodeVal(0x1000) // Trzy bajty 0x20100011 );Pokaż wszystkoPrzetestuj wartości jedno-, dwu- i trzybajtowe. Nie testujemy dalej, ponieważ zapisanie wystarczającej liczby wpisów na stosie (co najmniej 0x10000000, czyli około ćwierć miliarda) zajęłoby zbyt dużo czasu.
1 .2 .3 .4 .5 } // testEncodeValBig678 // Przetestuj, że przy zbyt małym buforze otrzymamy revert9 function testShortCalldata() public {Pokaż wszystkoPrzetestuj, co się stanie w nietypowym przypadku, gdy nie ma wystarczającej liczby parametrów.
1 .2 .3 .4 (_success, _callOutput) = _cacheAddr.call(_callInput);5 assertEq(_success, false);6 } // testShortCalldataPonieważ następuje wycofanie, wynikiem, który powinniśmy otrzymać, jest fałsz.
1 // Wywołanie z kluczami pamięci podręcznej, których tam nie ma2 function testNoCacheKey() public {3 .4 .5 .6 _callInput = bytes.concat(7 FOUR_PARAMS,89 // Pierwsza wartość, dodaj ją do pamięci podręcznej10 cache.INTO_CACHE(),11 bytes32(VAL_A),1213 // Druga wartość14 bytes1(0x0F),15 bytes2(0x1234),16 bytes11(0xA10102030405060708090A)17 );Pokaż wszystkoTa funkcja otrzymuje cztery całkowicie prawidłowe parametry, z wyjątkiem tego, że pamięć podręczna jest pusta, więc nie ma tam żadnych wartości do odczytania.
1 .2 .3 .4 // Przetestuj, że przy zbyt długim buforze wszystko działa poprawnie5 function testLongCalldata() public {6 address _cacheAddr = address(cache);7 bool _success;8 bytes memory _callInput;9 bytes memory _callOutput;1011 // Pierwsze wywołanie, pamięć podręczna jest pusta12 _callInput = bytes.concat(13 FOUR_PARAMS,1415 // Pierwsza wartość, dodaj ją do pamięci podręcznej16 cache.INTO_CACHE(), bytes32(VAL_A),1718 // Druga wartość, dodaj ją do pamięci podręcznej19 cache.INTO_CACHE(), bytes32(VAL_B),2021 // Trzecia wartość, dodaj ją do pamięci podręcznej22 cache.INTO_CACHE(), bytes32(VAL_C),2324 // Czwarta wartość, dodaj ją do pamięci podręcznej25 cache.INTO_CACHE(), bytes32(VAL_D),2627 // I jeszcze jedna wartość „na szczęście”28 bytes4(0x31112233)29 );Pokaż wszystkoTa funkcja wysyła pięć wartości. Wiemy, że piąta wartość jest ignorowana, ponieważ nie jest prawidłowym wpisem w pamięci podręcznej, co spowodowałoby wycofanie transakcji, gdyby nie została uwzględniona.
1 (_success, _callOutput) = _cacheAddr.call(_callInput);2 assertEq(_success, true);3 .4 .5 .6 } // testLongCalldata78} // CacheTest9Pokaż wszystkoPrzykładowa aplikacja
Pisanie testów w Solidity jest bardzo dobre, ale ostatecznie dapka musi być w stanie przetwarzać żądania spoza łańcucha, aby być użyteczną. Ten artykuł pokazuje, jak używać buforowania w dapce z WORM, co oznacza „Write Once, Read Many” (zapisz raz, czytaj wiele razy). Jeśli klucz nie jest jeszcze zapisany, można do niego zapisać wartość. Jeśli klucz jest już zapisany, następuje wycofanie transakcji.
Kontrakt
To jest kontrakt (opens in a new tab). W dużej mierze powtarza to, co zrobiliśmy już z Cache i CacheTest, więc omówimy tylko interesujące części.
1import "./Cache.sol";23contract WORM is Cache {Najłatwiejszym sposobem na użycie Cache jest odziedziczenie go w naszym własnym kontrakcie.
1 function writeEntryCached() external {2 uint[] memory params = _readParams(2);3 writeEntry(params[0], params[1]);4 } // writeEntryCachedTa funkcja jest podobna do fourParam w CacheTest powyżej. Ponieważ nie przestrzegamy specyfikacji ABI, najlepiej nie deklarować żadnych parametrów w funkcji.
1 // Ułatwienie wywoływania nas2 // Sygnatura funkcji dla writeEntryCached(), dzięki uprzejmości3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d34 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;Kod zewnętrzny, który wywołuje writeEntryCached, będzie musiał ręcznie zbudować dane wywołania (calldata), zamiast używać worm.writeEntryCached, ponieważ nie przestrzegamy specyfikacji ABI. Posiadanie tej stałej wartości po prostu ułatwia pisanie.
Należy pamiętać, że chociaż definiujemy WRITE_ENTRY_CACHED jako zmienną stanu, aby odczytać ją z zewnątrz, należy użyć funkcji pobierającej dla niej, worm.WRITE_ENTRY_CACHED().
1 function readEntry(uint key) public view2 returns (uint _value, address _writtenBy, uint _writtenAtBlock)Funkcja odczytu jest typu view, więc nie wymaga transakcji i nie kosztuje gazu. W rezultacie nie ma korzyści z używania pamięci podręcznej dla parametru. W przypadku funkcji view najlepiej jest używać standardowego mechanizmu, który jest prostszy.
Kod testowy
To jest kod testowy dla kontraktu (opens in a new tab). Ponownie, spójrzmy tylko na to, co jest interesujące.
1 function testWReadWrite() public {2 worm.writeEntry(0xDEAD, 0x60A7);34 vm.expectRevert(bytes("entry already written"));5 worm.writeEntry(0xDEAD, 0xBEEF);To (vm.expectRevert) (opens in a new tab) to sposób, w jaki w teście Foundry określamy, że następne wywołanie powinno zakończyć się niepowodzeniem, oraz podajemy przyczynę niepowodzenia. Dotyczy to sytuacji, gdy używamy składni <contract>.<function name>() zamiast budować dane wywołania (calldata) i wywoływać kontrakt przy użyciu interfejsu niskiego poziomu (<contract>.call() itp.).
1 function testReadWriteCached() public {2 uint cacheGoat = worm.cacheWrite(0x60A7);Tutaj wykorzystujemy fakt, że cacheWrite zwraca klucz pamięci podręcznej. Nie jest to coś, czego spodziewalibyśmy się używać w produkcji, ponieważ cacheWrite zmienia stan, a zatem może być wywoływane tylko podczas transakcji. Transakcje nie mają wartości zwracanych; jeśli mają wyniki, to te wyniki powinny być emitowane jako zdarzenia. Zatem wartość zwracana przez cacheWrite jest dostępna tylko z kodu onchain, a kod onchain nie potrzebuje buforowania parametrów.
1 (_success,) = address(worm).call(_callInput);W ten sposób mówimy Solidity, że chociaż <contract address>.call() ma dwie wartości zwracane, interesuje nas tylko pierwsza.
1 (_success,) = address(worm).call(_callInput);2 assertEq(_success, false);Ponieważ używamy funkcji niskiego poziomu <address>.call(), nie możemy użyć vm.expectRevert() i musimy sprawdzić wartość logiczną powodzenia, którą otrzymujemy z wywołania.
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);Pokaż wszystkoW ten sposób w Foundry weryfikujemy, czy kod poprawnie emituje zdarzenie (opens in a new tab).
Klient
Jedną rzeczą, której nie dostajesz w testach Solidity, jest kod JavaScript, który możesz wyciąć i wkleić do własnej aplikacji. Aby napisać ten kod, wdrożyłem WORM w Optimism Goerli (opens in a new tab), nowej sieci testowej Optimism (opens in a new tab). Znajduje się pod adresem 0xd34335b1d818cee54e3323d3246bd31d94e6a78a (opens in a new tab).
Kod JavaScript dla klienta można zobaczyć tutaj (opens in a new tab). Aby go użyć:
-
Sklonuj repozytorium git:
1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git -
Zainstaluj niezbędne pakiety:
1cd javascript2yarn -
Skopiuj plik konfiguracyjny:
1cp .env.example .env -
Edytuj
.envdla swojej konfiguracji:Parametr Wartość MNEMONIC Mnemonic dla konta, które ma wystarczająco dużo ETH, aby opłacić transakcję. Darmowe ETH dla sieci Optimism Goerli można uzyskać tutaj (opens in a new tab). OPTIMISM_GOERLI_URL URL do Optimism Goerli. Publiczny punkt końcowy, https://goerli.optimism.io, ma ograniczenia szybkości, ale jest wystarczający do tego, czego tutaj potrzebujemy -
Uruchom
index.js.1node index.jsTa przykładowa aplikacja najpierw zapisuje wpis do WORM, wyświetlając dane wywołania (calldata) i link do transakcji na Etherscan. Następnie odczytuje ten wpis i wyświetla używany klucz oraz wartości we wpisie (wartość, numer bloku i autor).
Większość klienta to normalny JavaScript Dapp. Dlatego ponownie przejdziemy tylko przez interesujące części.
1.2.3.4const main = async () => {5 const func = await worm.WRITE_ENTRY_CACHED()67 // Za każdym razem potrzebny jest nowy klucz8 const key = await worm.encodeVal(Number(new Date()))W danym slocie można zapisać tylko raz, więc używamy znacznika czasu, aby upewnić się, że nie używamy ponownie slotów.
1const val = await worm.encodeVal("0x600D")23// Zapisz wpis4const calldata = func + key.slice(2) + val.slice(2)Ethers oczekuje, że dane wywołania będą ciągiem szesnastkowym, 0x po którym następuje parzysta liczba cyfr szesnastkowych. Ponieważ zarówno key, jak i val zaczynają się od 0x, musimy usunąć te nagłówki.
1const tx = await worm.populateTransaction.writeEntryCached()2tx.data = calldata34sentTx = await wallet.sendTransaction(tx)Podobnie jak w przypadku kodu testowego Solidity, nie możemy normalnie wywołać funkcji buforowanej. Zamiast tego musimy użyć mechanizmu niższego poziomu.
1 .2 .3 .4 // Odczytaj właśnie zapisany wpis5 const realKey = '0x' + key.slice(4) // usuń flagę FF6 const entryRead = await worm.readEntry(realKey)7 .8 .9 .Pokaż wszystkoDo odczytywania wpisów możemy użyć normalnego mechanizmu. Nie ma potrzeby używania buforowania parametrów z funkcjami view.
Wnioski
Kod w tym artykule jest dowodem słuszności koncepcji, jego celem jest ułatwienie zrozumienia pomysłu. W przypadku systemu gotowego do produkcji można zaimplementować dodatkowe funkcje:
-
Obsługuj wartości, które nie są
uint256. Na przykład ciągi znaków. -
Zamiast globalnej pamięci podręcznej, można mieć mapowanie między użytkownikami a pamięciami podręcznymi. Różni użytkownicy używają różnych wartości.
-
Wartości używane dla adresów różnią się od tych używanych do innych celów. Może mieć sens posiadanie oddzielnej pamięci podręcznej tylko dla adresów.
-
Obecnie klucze pamięci podręcznej działają na zasadzie algorytmu „kto pierwszy, ten ma najmniejszy klucz”. Pierwsze szesnaście wartości można wysłać jako pojedynczy bajt. Następne 4080 wartości można wysłać jako dwa bajty. Następny około milion wartości to trzy bajty itd. System produkcyjny powinien utrzymywać liczniki użycia wpisów w pamięci podręcznej i reorganizować je tak, aby szesnaście najczęściej używanych wartości miało jeden bajt, następne 4080 najczęściej używanych wartości dwa bajty itd.
Jest to jednak potencjalnie niebezpieczna operacja. Wyobraź sobie następującą sekwencję zdarzeń:
-
Noam Naiwny wywołuje
encodeVal, aby zakodować adres, na który chce wysłać tokeny. Ten adres jest jednym z pierwszych używanych w aplikacji, więc zakodowana wartość to 0x06. Jest to funkcjaview, a nie transakcja, więc jest to sprawa między Noamem a węzłem, którego używa, i nikt inny o tym nie wie. -
Owen Właściciel uruchamia operację zmiany kolejności pamięci podręcznej. Bardzo niewiele osób faktycznie używa tego adresu, więc jest on teraz zakodowany jako 0x201122. Innej wartości, 1018, przypisano 0x06.
-
Noam Naiwny wysyła swoje tokeny na 0x06. Trafiają na adres
0x0000000000000000000000000de0b6b3a7640000, a ponieważ nikt nie zna klucza prywatnego do tego adresu, po prostu tam utknęły. Noam nie jest zadowolony.
Istnieją sposoby rozwiązania tego problemu i powiązanego z nim problemu transakcji znajdujących się w mempoolu podczas zmiany kolejności pamięci podręcznej, ale trzeba być tego świadomym.
-
Pokazałem tutaj buforowanie na przykładzie Optimism, ponieważ jestem pracownikiem Optimism i jest to pakiet zbiorczy, który znam najlepiej. Ale powinno to działać z każdym pakietem zbiorczym, który pobiera minimalny koszt za przetwarzanie wewnętrzne, tak że w porównaniu zapisywanie danych transakcji do L1 jest głównym wydatkiem.
Zobacz więcej mojej pracy tutaj (opens in a new tab).
Strona ostatnio zaktualizowana: 25 lutego 2026