Przejdź do głównej zawartości

Wszystko, co możesz zbuforować

warstwa 2
buforowanie
przechowywanie
Średnio zaawansowany
Ori Pomerantz
15 września 2022
21 minuta czytania

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:

  1. Jeśli pierwszy bajt to 0xFF, weź następne 32 bajty jako wartość parametru i zapisz ją w pamięci podręcznej.

  2. Jeśli pierwszy bajt to 0xFE, weź następne 32 bajty jako wartość parametru, ale nie zapisuj jej w pamięci podręcznej.

  3. 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 calldataKlucz pamięci podręcznej
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

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: 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;

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.

1
2 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 } // cacheRead

Odczytaj wartość z pamięci podręcznej.

1 // Zapisz wartość w pamięci podręcznej, jeśli jeszcze jej tam nie ma
2 // Funkcja publiczna tylko po to, aby umożliwić działanie testu
3 function cacheWrite(uint _value) public returns (uint) {
4 // Jeśli wartość jest już w pamięci podręcznej, zwróć bieżący klucz
5 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, to
2 // 0x0D, po którym następuje 15 wartości 0xFF. Jeśli długość pamięci podręcznej jest już tak
3 // duża, zakończ błędem.
4 // 1 2 3 4 5 6 7 8 9 A B C D E F
5 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 klucza
2 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 } // cacheWrite

Zwróć 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;
3
4 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 } // _calldataVal
3
4
5 // Odczytaj pojedynczy parametr z calldata, zaczynając od _fromByte
6 function _readParam(uint _fromByte) internal
7 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;
3
4 _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.

1
2 // Odczytaj wartość, ale nie zapisuj jej w pamięci podręcznej
3 if (_firstByte == uint8(DONT_CACHE))
4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));
5
6 // Odczytaj wartość i zapisz ją w pamięci podręcznej
7 if (_firstByte == uint8(INTO_CACHE)) {
8 uint _param = _calldataVal(_fromByte+1, 32);
9 cacheWrite(_param);
10 return(_fromByte+33, _param);
11 }
12
13 // Jeśli dotarliśmy tutaj, oznacza to, że musimy odczytać z pamięci podręcznej
14
15 // Liczba dodatkowych bajtów do odczytania
16 uint8 _extraBytes = _firstByte / 16;
Pokaż wszystko

Weź 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);
3
4 return (_fromByte+_extraBytes+1, cacheRead(_key));
5
6 } // _readParam
7
8
9 // Odczytaj n parametrów (funkcje wiedzą, ilu parametrów oczekują)
10 function _readParams(uint _paramNum) internal returns (uint[] memory) {
Pokaż wszystko

Moglibyś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 odczytujemy
2 uint[] memory params = new uint[](_paramNum);
3
4 // Parametry zaczynają się od 4 bajtu, przed nim jest sygnatura funkcji
5 uint _atByte = 4;
6
7 for(uint i=0; i<_paramNum; i++) {
8 (_atByte, params[i]) = _readParam(_atByte);
9 }
Pokaż wszystko

Odczytuj parametry, aż uzyskasz potrzebną liczbę. Jeśli wykroczymy poza koniec calldata, _readParams cofnie wywołanie.

1
2 return(params);
3 } // readParams
4
5 // Do testowania _readParams, przetestuj odczyt czterech parametrów
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
Pokaż wszystko

Jedną 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];
2
3 // 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 bajt
2 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 0x1vvv
2 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 i
3 // prostoty.
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)));
Pokaż wszystko

Pozostał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 } // encodeVal
2
3} // Cache

Testowanie 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 Cachetutaj (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: UNLICENSED
2pragma solidity ^0.8.13;
3
4import "forge-std/Test.sol";
5
6
7// 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;
3
4 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 }
4
5 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 } // testCaching
3
4
5 // Zapisz tę samą wartość wiele razy, upewnij się, że klucz pozostaje
6 // taki sam
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 }
Pokaż wszystko

Najpierw 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 } // testRepeatCaching

Teoretycznie 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 pure
4 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;
4
5 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.

1
2 return tempUint;
3 } // toUint256
4
5 // Sygnatura funkcji dla fourParams(), dzięki uprzejmości
6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
8
9 // Po prostu kilka stałych wartości, aby zobaczyć, czy otrzymujemy poprawne wartości z powrotem
10 uint256 constant VAL_A = 0xDEAD60A7;
11 uint256 constant VAL_B = 0xBEEF;
12 uint256 constant VAL_C = 0x600D;
13 uint256 constant VAL_D = 0x600D60A7;
Pokaż wszystko

Kilka 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 pusta
2 _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ęcznej
2 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ęcznej
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
4
5 // Pierwsza wartość w pamięci podręcznej
6 bytes1(0x01),

Klucze pamięci podręcznej poniżej 16 to tylko jeden bajt.

1 // Druga wartość, nie dodawaj jej do pamięci podręcznej
2 cache.DONT_CACHE(),
3 bytes32(VAL_B),
4
5 // Trzecia i czwarta wartość, ta sama wartość
6 bytes1(0x02),
7 bytes1(0x02)
8 );
9 .
10 .
11 .
12 } // testReadParam
Pokaż wszystko

Testy 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 } // testEncodeVal
Pokaż wszystko

Jedynym 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 bajt
2 // Maksymalnie trzy bajty, ponieważ wypełnienie pamięci podręcznej do czterech bajtów trwa
3 // 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ż wszystko

Powyż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 .
4
5 _callInput = bytes.concat(
6 FOUR_PARAMS,
7 cache.encodeVal(0x000F), // Jeden bajt 0x0F
8 cache.encodeVal(0x0010), // Dwa bajty 0x1010
9 cache.encodeVal(0x0100), // Dwa bajty 0x1100
10 cache.encodeVal(0x1000) // Trzy bajty 0x201000
11 );
Pokaż wszystko

Przetestuj 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 } // testEncodeValBig
6
7
8 // Przetestuj, że przy zbyt małym buforze otrzymamy revert
9 function testShortCalldata() public {
Pokaż wszystko

Przetestuj, 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 } // testShortCalldata

Ponieważ 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 ma
2 function testNoCacheKey() public {
3 .
4 .
5 .
6 _callInput = bytes.concat(
7 FOUR_PARAMS,
8
9 // Pierwsza wartość, dodaj ją do pamięci podręcznej
10 cache.INTO_CACHE(),
11 bytes32(VAL_A),
12
13 // Druga wartość
14 bytes1(0x0F),
15 bytes2(0x1234),
16 bytes11(0xA10102030405060708090A)
17 );
Pokaż wszystko

Ta 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 poprawnie
5 function testLongCalldata() public {
6 address _cacheAddr = address(cache);
7 bool _success;
8 bytes memory _callInput;
9 bytes memory _callOutput;
10
11 // Pierwsze wywołanie, pamięć podręczna jest pusta
12 _callInput = bytes.concat(
13 FOUR_PARAMS,
14
15 // Pierwsza wartość, dodaj ją do pamięci podręcznej
16 cache.INTO_CACHE(), bytes32(VAL_A),
17
18 // Druga wartość, dodaj ją do pamięci podręcznej
19 cache.INTO_CACHE(), bytes32(VAL_B),
20
21 // Trzecia wartość, dodaj ją do pamięci podręcznej
22 cache.INTO_CACHE(), bytes32(VAL_C),
23
24 // Czwarta wartość, dodaj ją do pamięci podręcznej
25 cache.INTO_CACHE(), bytes32(VAL_D),
26
27 // I jeszcze jedna wartość „na szczęście”
28 bytes4(0x31112233)
29 );
Pokaż wszystko

Ta 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 } // testLongCalldata
7
8} // CacheTest
9
Pokaż wszystko

Przykł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";
2
3contract 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 } // writeEntryCached

Ta 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 nas
2 // Sygnatura funkcji dla writeEntryCached(), dzięki uprzejmości
3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
4 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 view
2 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);
3
4 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);
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);
Pokaż wszystko

W 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ć:

  1. Sklonuj repozytorium git:

    1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
  2. Zainstaluj niezbędne pakiety:

    1cd javascript
    2yarn
  3. Skopiuj plik konfiguracyjny:

    1cp .env.example .env
  4. Edytuj .env dla swojej konfiguracji:

    ParametrWartość
    MNEMONICMnemonic 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_URLURL do Optimism Goerli. Publiczny punkt końcowy, https://goerli.optimism.io, ma ograniczenia szybkości, ale jest wystarczający do tego, czego tutaj potrzebujemy
  5. Uruchom index.js.

    1node index.js

    Ta 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()
6
7 // Za każdym razem potrzebny jest nowy klucz
8 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")
2
3// Zapisz wpis
4const 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 = calldata
3
4sentTx = 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 wpis
5 const realKey = '0x' + key.slice(4) // usuń flagę FF
6 const entryRead = await worm.readEntry(realKey)
7 .
8 .
9 .
Pokaż wszystko

Do 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ń:

    1. 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 funkcja view, 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.

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

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

Czy ten samouczek był pomocny?