Wszystko, co możesz zbuforować
Podczas korzystania z rollupów koszt bajtu w transakcji jest znacznie wyższy niż koszt slotu przechowywania. Dlatego ma sens buforowanie (caching) jak największej ilości informacji onchain.
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 zostanie użyta wielokrotnie, została zbuforowana i była gotowa do użycia (po pierwszym razie) przy użyciu znacznie mniejszej liczby bajtów, a także jak napisać kod pozałańcuchowy, który korzysta z tej pamięci podręcznej.
Jeśli chcesz pominąć artykuł i po prostu zobaczyć kod źródłowy, znajdziesz go tutaj (opens in a new tab). Stos technologiczny 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. Kiedy otrzymamy transakcję, przeanalizujemy 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 najbardziej znaczące bity jako liczbę dodatkowych bajtów, a cztery najmniej znaczące bity jako najbardziej znaczące bity klucza pamięci podręcznej. Oto kilka przykładów:
Bajty w danych wywołania 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). Przeanalizujmy to linijka po linijce.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Cache {
bytes1 public constant INTO_CACHE = 0xFF;
bytes1 public constant DONT_CACHE = 0xFE;
Te stałe są używane do interpretacji specjalnych przypadków, w których podajemy wszystkie informacje i chcemy, aby zostały zapisane w pamięci podręcznej lub nie. Zapis do pamięci podręcznej wymaga dwóch operacji SSTORE (opens in a new tab) w nieużywanych wcześniej slotach przechowywania, co kosztuje 22100 gazu za każdą, więc robimy to opcjonalnie.
mapping(uint => uint) public val2key;
Mapowanie (opens in a new tab) między wartościami a ich kluczami. Ta informacja jest niezbędna do zakodowania wartości przed wysłaniem transakcji.
// Lokalizacja n ma wartość dla klucza n+1, ponieważ musimy zachować
// zero jako "brak w pamięci podręcznej".
uint[] public key2val;
Możemy użyć tablicy do mapowania z kluczy na wartości, ponieważ to my przypisujemy klucze, a dla uproszczenia robimy to sekwencyjnie.
function cacheRead(uint _key) public view returns (uint) {
require(_key <= key2val.length, "Reading uninitialize cache entry");
return key2val[_key-1];
} // cacheRead
Odczytaj wartość z pamięci podręcznej.
// Zapisz wartość w pamięci podręcznej, jeśli jeszcze jej tam nie ma
// Tylko publiczne, aby umożliwić działanie testu
function cacheWrite(uint _value) public returns (uint) {
// Jeśli wartość jest już w pamięci podręcznej, zwróć bieżący klucz
if (val2key[_value] != 0) {
return val2key[_value];
}
Nie ma sensu umieszczać tej samej wartości w pamięci podręcznej więcej niż raz. Jeśli wartość już tam jest, po prostu zwróć istniejący klucz.
// Ponieważ 0xFE jest przypadkiem szczególnym, największy klucz, jaki może
// pomieścić pamięć podręczna, to 0x0D, po którym następuje 15 0xFF. Jeśli długość pamięci podręcznej jest już tak
// duża, zakończ niepowodzeniem.
// 1 2 3 4 5 6 7 8 9 A B C D E F
require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
"cache overflow");
Nie sądzę, byśmy kiedykolwiek uzyskali tak dużą pamięć podręczną (około 1.8*1037 wpisów, co wymagałoby około 1027 TB do przechowania). Jednak jestem wystarczająco stary, by pamiętać, że „640kB zawsze wystarczy” (opens in a new tab). Ten test jest bardzo tani.
// Zapisz wartość używając następnego klucza
val2key[_value] = key2val.length+1;
Dodaj odwrotne wyszukiwanie (od wartości do klucza).
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.
return key2val.length;
} // cacheWrite
Zwróć nową długość key2val, która jest komórką, w której przechowywana jest nowa wartość.
function _calldataVal(uint startByte, uint length)
private pure returns (uint)
Ta funkcja odczytuje wartość z danych wywołania o dowolnej długości (do 32 bajtów, rozmiar słowa).
{
uint _retVal;
require(length < 0x21,
"_calldataVal length limit is 32 bytes");
require(length + startByte <= msg.data.length,
"_calldataVal trying to read beyond calldatasize");
Ta funkcja jest wewnętrzna, więc jeśli reszta kodu jest napisana poprawnie, te testy nie są wymagane. Jednak nie kosztują one wiele, więc równie dobrze możemy je mieć.
assembly {
_retVal := calldataload(startByte)
}
Ten kod jest w Yul (opens in a new tab). Odczytuje 32-bajtową wartość z danych wywołania. Działa to nawet wtedy, gdy dane wywołania kończą się przed startByte+32, ponieważ niezainicjowana przestrzeń w EVM jest uważana za zero.
_retVal = _retVal >> (256-length*8);
Niekoniecznie chcemy 32-bajtowej wartości. To pozbywa się nadmiarowych bajtów.
return _retVal;
} // _calldataVal
// Odczytaj pojedynczy parametr z danych wywołania, zaczynając od _fromByte
function _readParam(uint _fromByte) internal
returns (uint _nextByte, uint _parameterValue)
{
Odczytaj pojedynczy parametr z danych wywołania. Zauważ, że musimy zwrócić nie tylko odczytaną wartość, ale także lokalizację następnego bajtu, ponieważ parametry mogą mieć długość od 1 bajtu do 33 bajtów.
// Pierwszy bajt mówi nam, jak zinterpretować resztę
uint8 _firstByte;
_firstByte = uint8(_calldataVal(_fromByte, 1));
Solidity próbuje zmniejszyć liczbę błędów, zabraniając potencjalnie niebezpiecznych niejawnych konwersji typów (opens in a new tab). Zmniejszenie rozmiaru, na przykład z 256 bitów do 8 bitów, musi być jawne.
// Odczytaj wartość, ale nie zapisuj jej w pamięci podręcznej
if (_firstByte == uint8(DONT_CACHE))
return(_fromByte+33, _calldataVal(_fromByte+1, 32));
// Odczytaj wartość i zapisz ją w pamięci podręcznej
if (_firstByte == uint8(INTO_CACHE)) {
uint _param = _calldataVal(_fromByte+1, 32);
cacheWrite(_param);
return(_fromByte+33, _param);
}
// Jeśli tu dotarliśmy, oznacza to, że musimy odczytać z pamięci podręcznej
// Liczba dodatkowych bajtów do odczytania
uint8 _extraBytes = _firstByte / 16;
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.
uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +
_calldataVal(_fromByte+1, _extraBytes);
return (_fromByte+_extraBytes+1, cacheRead(_key));
} // _readParam
// Odczytaj n parametrów (funkcje wiedzą, ilu parametrów oczekują)
function _readParams(uint _paramNum) internal returns (uint[] memory) {
Moglibyśmy uzyskać liczbę parametrów z samych danych wywołania, ale funkcje, które nas wywołują, wiedzą, ilu parametrów oczekują. Łatwiej jest pozwolić im nam to powiedzieć.
// Odczytane przez nas parametry
uint[] memory params = new uint[](_paramNum);
// Parametry zaczynają się od bajtu 4, wcześniej znajduje się sygnatura funkcji
uint _atByte = 4;
for(uint i=0; i<_paramNum; i++) {
(_atByte, params[i]) = _readParam(_atByte);
}
Odczytuj parametry, aż uzyskasz potrzebną liczbę. Jeśli wyjdziemy poza koniec danych wywołania, _readParams spowoduje wycofanie wywołania.
return(params);
} // readParams
// Do testowania _readParams, przetestuj odczyt czterech parametrów
function fourParam() public
returns (uint256,uint256,uint256,uint256)
{
uint[] memory params;
params = _readParams(4);
return (params[0], params[1], params[2], params[3]);
} // fourParam
Jedną z dużych 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ć, czy były poprawne.
// Pobierz wartość, zwróć bajty, które ją zakodują (używając pamięci podręcznej, jeśli to możliwe)
function encodeVal(uint _val) public view returns(bytes memory) {
encodeVal to funkcja, którą wywołuje kod pozałańcuchowy, aby pomóc w tworzeniu danych wywołania korzystających z pamięci podręcznej. Otrzymuje pojedynczą wartość i zwraca bajty, które ją kodują. Ta funkcja to view, więc nie wymaga transakcji, a wywołana z zewnątrz nie kosztuje żadnego gazu.
uint _key = val2key[_val];
// Wartości nie ma jeszcze w pamięci podręcznej, dodaj ją
if (_key == 0)
return bytes.concat(INTO_CACHE, bytes32(_val));
W EVM zakłada się, że całe niezainicjowane przechowywanie to 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 następuje rzeczywista wartość.
// Jeśli klucz to <0x10, zwróć go jako pojedynczy bajt
if (_key < 0x10)
return bytes.concat(bytes1(uint8(_key)));
Pojedyncze bajty są najprostsze. Po prostu używamy bytes.concat (opens in a new tab), aby zamienić typ bytes<n> w tablicę bajtów, która może mieć dowolną długość. Mimo nazwy, działa to świetnie, gdy podamy tylko jeden argument.
// Wartość dwubajtowa, zakodowana jako 0x1vvv
if (_key < 0x1000)
return bytes.concat(bytes2(uint16(_key) | 0x1000));
Kiedy mamy klucz mniejszy niż 163, możemy go wyrazić w dwóch bajtach. Najpierw konwertujemy _key, który jest wartością 256-bitową, na wartość 16-bitową i używamy logicznego OR, aby dodać liczbę dodatkowych bajtów do pierwszego bajtu. Następnie po prostu rzutujemy to na wartość bytes2, którą można przekonwertować na bytes.
// Prawdopodobnie istnieje sprytny sposób na wykonanie poniższych wierszy jako pętli,
// ale jest to funkcja view, więc optymalizuję pod kątem czasu programisty i
// prostoty.
if (_key < 16*256**2)
return bytes.concat(bytes3(uint24(_key) | (0x2 * 16 * 256**2)));
if (_key < 16*256**3)
return bytes.concat(bytes4(uint32(_key) | (0x3 * 16 * 256**3)));
.
.
.
if (_key < 16*256**14)
return bytes.concat(bytes15(uint120(_key) | (0xE * 16 * 256**14)));
if (_key < 16*256**15)
return bytes.concat(bytes16(uint128(_key) | (0xF * 16 * 256**15)));
Pozostałe wartości (3 bajty, 4 bajty itd.) są obsługiwane w ten sam sposób, tylko z różnymi rozmiarami pól.
// Jeśli tu dotarliśmy, coś jest nie tak.
revert("Error in encodeVal, should not happen");
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 nie kosztuje nas wiele dodanie testu na wypadek, gdyby przyszły programista wprowadził błąd.
} // encodeVal
} // 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 Cache znajdują się tutaj (opens in a new tab). Ponieważ kod testujący jest powtarzalny, jak to zwykle bywa z testami, ten artykuł wyjaśnia tylko interesujące części.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
// Należy uruchomić `forge test -vv` dla konsoli.
import "forge-std/console.sol";
To tylko kod szablonowy (boilerplate), który jest niezbędny do korzystania z pakietu testowego i console.log.
import "src/Cache.sol";
Musimy znać kontrakt, który testujemy.
contract CacheTest is Test {
Cache cache;
function setUp() public {
cache = new Cache();
}
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.
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 odczytując je ponownie.
for(uint i=1; i<5000; i++) {
cache.cacheWrite(i*i);
}
for(uint i=1; i<5000; i++) {
assertEq(cache.cacheRead(i), i*i);
Tak przeprowadza się właściwe testowanie, używając funkcji assert... (opens in a new tab). W tym przypadku sprawdzamy, czy zapisana wartość jest tą, którą odczytaliśmy. Możemy odrzucić wynik cache.cacheWrite, ponieważ wiemy, że klucze pamięci podręcznej są przypisywane liniowo.
}
} // testCaching
// Zapisz tę samą wartość w pamięci podręcznej wiele razy, upewnij się, że klucz pozostaje
// taki sam
function testRepeatCaching() public {
for(uint i=1; i<100; i++) {
uint _key1 = cache.cacheWrite(i);
uint _key2 = cache.cacheWrite(i);
assertEq(_key1, _key2);
}
Najpierw zapisujemy każdą wartość dwukrotnie w pamięci podręcznej i upewniamy się, że klucze są takie same (co oznacza, że drugi zapis tak naprawdę się nie odbył).
for(uint i=1; i<100; i+=3) {
uint _key = cache.cacheWrite(i);
assertEq(_key, i);
}
} // testRepeatCaching
W teorii mógłby istnieć błąd, który nie wpływa na kolejne zapisy w pamięci podręcznej. Więc tutaj wykonujemy kilka zapisów, które nie są po kolei, i sprawdzamy, czy wartości nadal nie są nadpisywane.
// Odczytaj uint z bufora pamięci (aby upewnić się, że otrzymujemy z powrotem parametry,
// które wysłaliśmy)
function toUint256(bytes memory _bytes, uint256 _start) internal pure
returns (uint256)
Odczytaj 256-bitowe słowo z bufora bytes memory. Ta funkcja pomocnicza pozwala nam zweryfikować, czy otrzymujemy poprawne wyniki, gdy uruchamiamy wywołanie funkcji korzystającej z pamięci podręcznej.
{
require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
uint256 tempUint;
assembly {
tempUint := mload(add(add(_bytes, 0x20), _start))
}
Yul nie obsługuje struktur danych poza uint256, więc kiedy 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 zawierające długość, po którym następują rzeczywiste bajty, więc aby uzyskać bajt numer _start, musimy obliczyć _bytes+32+_start.
return tempUint;
} // toUint256
// Sygnatura funkcji dla fourParams(), dzięki uprzejmości
// https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
// Tylko kilka stałych wartości, aby sprawdzić, czy otrzymujemy z powrotem poprawne wartości
uint256 constant VAL_A = 0xDEAD60A7;
uint256 constant VAL_B = 0xBEEF;
uint256 constant VAL_C = 0x600D;
uint256 constant VAL_D = 0x600D60A7;
Kilka stałych, których potrzebujemy do testowania.
function testReadParam() public {
Wywołaj fourParams(), funkcję, która używa readParams, aby przetestować, czy możemy poprawnie odczytać parametry.
address _cacheAddr = address(cache);
bool _success;
bytes memory _callInput;
bytes memory _callOutput;
Nie możemy użyć normalnego mechanizmu ABI do wywołania funkcji korzystającej z pamięci podręcznej, więc musimy użyć niskopoziomowego mechanizmu <address>.call() (opens in a new tab). Ten mechanizm przyjmuje bytes memory jako wejście i zwraca to samo (a także wartość logiczną) jako wyjście.
// Pierwsze wywołanie, pamięć podręczna jest pusta
_callInput = bytes.concat(
FOUR_PARAMS,
Przydatne 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 odpowiedniej funkcji, zamiast umieszczać wszystko w funkcji fallback (opens in a new tab). Takie podejście znacznie ułatwia kompozycyjność. W większości przypadków pojedynczy bajt wystarczyłby do zidentyfikowania funkcji, więc marnujemy trzy bajty (16*3=48 gazu). Jednak w momencie pisania tego tekstu te 48 gazu kosztuje 0,07 centa, co jest rozsądnym kosztem za prostszy, mniej podatny na błędy kod.
// Pierwsza wartość, dodaj ją do pamięci podręcznej
cache.INTO_CACHE(),
bytes32(VAL_A),
Pierwsza wartość: Flaga mówiąca, że jest to pełna wartość, która musi zostać zapisana w pamięci podręcznej, po której następuje 32 bajty wartości. Pozostałe trzy wartości są podobne, z tą różnicą, że VAL_B nie jest zapisywana w pamięci podręcznej, a VAL_C jest zarówno trzecim, jak i czwartym parametrem.
.
.
.
);
(_success, _callOutput) = _cacheAddr.call(_callInput);
To tutaj faktycznie wywołujemy kontrakt Cache.
assertEq(_success, true);
Oczekujemy, że wywołanie zakończy się sukcesem.
assertEq(cache.cacheRead(1), VAL_A);
assertEq(cache.cacheRead(2), VAL_C);
Zaczynamy od pustej pamięci podręcznej, a następnie dodajemy VAL_A, a po nim VAL_C. Oczekujemy, że pierwsza z nich będzie miała klucz 1, a druga 2.
assertEq(toUint256(_callOutput,0), VAL_A);
assertEq(toUint256(_callOutput,32), VAL_B);
assertEq(toUint256(_callOutput,64), VAL_C);
assertEq(toUint256(_callOutput,96), VAL_C);
Wyjściem są cztery parametry. Tutaj weryfikujemy, czy jest poprawne.
// Drugie wywołanie, możemy użyć pamięci podręcznej
_callInput = bytes.concat(
FOUR_PARAMS,
// Pierwsza wartość w pamięci podręcznej
bytes1(0x01),
Klucze pamięci podręcznej poniżej 16 to tylko jeden bajt.
// Druga wartość, nie dodawaj jej do pamięci podręcznej
cache.DONT_CACHE(),
bytes32(VAL_B),
// Trzecia i czwarta wartość, ta sama wartość
bytes1(0x02),
bytes1(0x02)
);
.
.
.
} // testReadParam
Testy po wywołaniu są identyczne z tymi po pierwszym wywołaniu.
function testEncodeVal() public {
Ta funkcja jest podobna do testReadParam, z tą różnicą, że zamiast jawnie zapisywać parametry, używamy encodeVal().
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
cache.encodeVal(VAL_A),
cache.encodeVal(VAL_B),
cache.encodeVal(VAL_C),
cache.encodeVal(VAL_D)
);
.
.
.
assertEq(_callInput.length, 4+1*4);
} // testEncodeVal
Jedynym dodatkowym testem w testEncodeVal() jest weryfikacja, czy długość _callInput jest poprawna. Dla pierwszego wywołania wynosi ona 4+33*4. Dla drugiego, gdzie każda wartość jest już w pamięci podręcznej, wynosi 4+1*4.
// Przetestuj encodeVal, gdy klucz ma więcej niż jeden bajt
// Maksymalnie trzy bajty, ponieważ wypełnienie pamięci podręcznej do czterech bajtów zajmuje
// zbyt dużo czasu.
function testEncodeValBig() public {
// Umieść kilka wartości w pamięci podręcznej.
// Aby uprościć sprawę, użyj klucza n dla wartości n.
for(uint i=1; i<0x1FFF; i++) {
cache.cacheWrite(i);
}
Powyższa funkcja testEncodeVal zapisuje tylko cztery wartości w pamięci podręcznej, więc część funkcji zajmująca 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 zapisuje wszystkie wartości od 1 do 0x1FFF w pamięci podręcznej w kolejności, dzięki czemu będziemy w stanie zakodować te wartości i wiedzieć, dokąd trafiają.
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
cache.encodeVal(0x000F), // Jeden bajt 0x0F
cache.encodeVal(0x0010), // Dwa bajty 0x1010
cache.encodeVal(0x0100), // Dwa bajty 0x1100
cache.encodeVal(0x1000) // Trzy bajty 0x201000
);
Przetestuj wartości jedno-, dwu- i trzybajtowe. Nie testujemy dalej, ponieważ zapisanie wystarczającej liczby wpisów na stosie zajęłoby zbyt dużo czasu (co najmniej 0x10000000, czyli około ćwierć miliarda).
.
.
.
.
} // testEncodeValBig
// Przetestuj, czy przy zbyt małym buforze otrzymamy wycofanie
function testShortCalldata() public {
Przetestuj, co się dzieje w nietypowym przypadku, gdy nie ma wystarczającej liczby parametrów.
.
.
.
(_success, _callOutput) = _cacheAddr.call(_callInput);
assertEq(_success, false);
} // testShortCalldata
Ponieważ następuje wycofanie, wynikiem, który powinniśmy otrzymać, jest false.
// Call with cache keys that aren't there
function testNoCacheKey() public {
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
// Pierwsza wartość, dodaj ją do pamięci podręcznej
cache.INTO_CACHE(),
bytes32(VAL_A),
// Second value
bytes1(0x0F),
bytes2(0x1234),
bytes11(0xA10102030405060708090A)
);
Ta funkcja otrzymuje cztery całkowicie poprawne parametry, z tą różnicą, że pamięć podręczna jest pusta, więc nie ma tam żadnych wartości do odczytania.
.
.
.
// Przetestuj, czy przy zbyt długim buforze wszystko działa poprawnie
function testLongCalldata() public {
address _cacheAddr = address(cache);
bool _success;
bytes memory _callInput;
bytes memory _callOutput;
// Pierwsze wywołanie, pamięć podręczna jest pusta
_callInput = bytes.concat(
FOUR_PARAMS,
// First value, add it to the cache
cache.INTO_CACHE(), bytes32(VAL_A),
// Druga wartość, dodaj ją do pamięci podręcznej
cache.INTO_CACHE(), bytes32(VAL_B),
// Trzecia wartość, dodaj ją do pamięci podręcznej
cache.INTO_CACHE(), bytes32(VAL_C),
// Czwarta wartość, dodaj ją do pamięci podręcznej
cache.INTO_CACHE(), bytes32(VAL_D),
// I jeszcze jedna wartość na "szczęście"
bytes4(0x31112233)
);
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, gdyby nie została uwzględniona.
(_success, _callOutput) = _cacheAddr.call(_callInput);
assertEq(_success, true);
.
.
.
} // testLongCalldata
} // CacheTest
Przykładowa aplikacja
Pisanie testów w Solidity jest bardzo dobre, ale ostatecznie zdecentralizowana aplikacja (dapp) musi być w stanie przetwarzać żądania spoza łańcucha, aby była użyteczna. Ten artykuł demonstruje, jak używać pamięci podręcznej w dapp z WORM, co oznacza „Write Once, Read Many” (Zapisz raz, czytaj wiele razy). Jeśli klucz nie jest jeszcze zapisany, możesz zapisać do niego wartość. Jeśli klucz jest już zapisany, następuje wycofanie.
Kontrakt
Oto kontrakt (opens in a new tab). W większości powtarza to, co już zrobiliśmy z Cache i CacheTest, więc omówimy tylko interesujące części.
import "./Cache.sol";
contract WORM is Cache {
Najprostszym sposobem na użycie Cache jest odziedziczenie go w naszym własnym kontrakcie.
function writeEntryCached() external {
uint[] memory params = _readParams(2);
writeEntry(params[0], params[1]);
} // writeEntryCached
Ta funkcja jest podobna do fourParam w CacheTest powyżej. Ponieważ nie przestrzegamy specyfikacji ABI, najlepiej nie deklarować żadnych parametrów w funkcji.
// Ułatw wywoływanie nas
// Sygnatura funkcji dla writeEntryCached(), dzięki uprzejmości
// https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;
Zewnętrzny kod, który wywołuje writeEntryCached, będzie musiał ręcznie zbudować dane wywołania, zamiast używać worm.writeEntryCached, ponieważ nie przestrzegamy specyfikacji ABI. Posiadanie tej stałej wartości po prostu ułatwia jej napisanie.
Zauważ, że chociaż definiujemy WRITE_ENTRY_CACHED jako zmienną stanu, aby odczytać ją z zewnątrz, konieczne jest użycie dla niej funkcji pobierającej (getter), worm.WRITE_ENTRY_CACHED().
function readEntry(uint key) public view
returns (uint _value, address _writtenBy, uint _writtenAtBlock)
Funkcja odczytu to 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 widoku (view functions) najlepiej jest użyć standardowego mechanizmu, który jest prostszy.
Kod testujący
Oto kod testujący dla kontraktu (opens in a new tab). Ponownie, spójrzmy tylko na to, co interesujące.
function testWReadWrite() public {
worm.writeEntry(0xDEAD, 0x60A7);
vm.expectRevert(bytes("entry already written"));
worm.writeEntry(0xDEAD, 0xBEEF);
W ten sposób (vm.expectRevert) (opens in a new tab) określamy w teście Foundry, że następne wywołanie powinno się nie powieść, oraz zgłaszaną przyczynę niepowodzenia. Ma to zastosowanie, gdy używamy składni <contract>.<function name>() zamiast budowania danych wywołania i wywoływania kontraktu za pomocą interfejsu niskopoziomowego (<contract>.call() itp.).
function testReadWriteCached() public {
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żyć w produkcji, ponieważ cacheWrite zmienia stan, a zatem może być wywołane tylko podczas transakcji. Transakcje nie mają wartości zwracanych; jeśli mają wyniki, 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.
(_success,) = address(worm).call(_callInput);
W ten sposób mówimy Solidity, że chociaż <contract address>.call() ma dwie wartości zwracane, zależy nam tylko na pierwszej.
(_success,) = address(worm).call(_callInput);
assertEq(_success, false);
Ponieważ używamy niskopoziomowej funkcji <address>.call(), nie możemy użyć vm.expectRevert() i musimy spojrzeć na logiczną wartość sukcesu, którą otrzymujemy z wywołania.
event EntryWritten(uint indexed key, uint indexed value);
.
.
.
_callInput = bytes.concat(
worm.WRITE_ENTRY_CACHED(), worm.encodeVal(a), worm.encodeVal(b));
vm.expectEmit(true, true, false, false);
emit EntryWritten(a, b);
(_success,) = address(worm).call(_callInput);
W ten sposób weryfikujemy, czy kod poprawnie emituje zdarzenie (opens in a new tab) w Foundry.
Klient
Jedną rzeczą, której nie otrzymujesz z testami Solidity, jest kod JavaScript, który możesz wyciąć i wkleić do własnej aplikacji. Aby napisać ten kod, wdrożyłem WORM do 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żesz zobaczyć tutaj (opens in a new tab). Aby go użyć:
-
Sklonuj repozytorium git:
git clone https://github.com/qbzzt/20220915-all-you-can-cache.git -
Zainstaluj niezbędne pakiety:
cd javascript yarn -
Skopiuj plik konfiguracyjny:
cp .env.example .env -
Edytuj
.envdla swojej konfiguracji:Parametr Wartość MNEMONIC Mnemotechnika (fraza seed) dla konta, które ma wystarczająco dużo ETH, aby zapłacić za transakcję. Darmowe ETH dla sieci Optimism Goerli możesz zdobyć tutaj (opens in a new tab). OPTIMISM_GOERLI_URL Adres URL do Optimism Goerli. Publiczny punkt końcowy, https://goerli.optimism.io, ma ograniczenia przepustowości (rate limited), ale jest wystarczający do naszych potrzeb -
Uruchom
index.js.node index.jsTa przykładowa aplikacja najpierw zapisuje wpis do WORM, wyświetlając dane wywołania i link do transakcji w Etherscan. Następnie odczytuje ten wpis z powrotem i wyświetla używany klucz oraz wartości we wpisie (wartość, numer bloku i autor).
Większość klienta to normalny JavaScript dla dapp. Więc ponownie omówimy tylko interesujące części.
.
.
.
const main = async () => {
const func = await worm.WRITE_ENTRY_CACHED()
// Za każdym razem potrzebny jest nowy klucz
const key = await worm.encodeVal(Number(new Date()))
Do danego slotu można zapisać tylko raz, więc używamy znacznika czasu (timestamp), aby upewnić się, że nie używamy ponownie tych samych slotów.
const val = await worm.encodeVal("0x600D")
// Zapisz wpis
const 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.
const tx = await worm.populateTransaction.writeEntryCached()
tx.data = calldata
sentTx = await wallet.sendTransaction(tx)
Podobnie jak w przypadku kodu testującego Solidity, nie możemy normalnie wywołać buforowanej funkcji. Zamiast tego musimy użyć mechanizmu niższego poziomu.
.
.
.
// Odczytaj właśnie zapisany wpis
const realKey = '0x' + key.slice(4) // usuń flagę FF
const entryRead = await worm.readEntry(realKey)
.
.
.
Do odczytywania wpisów możemy użyć normalnego mechanizmu. Nie ma potrzeby używania buforowania parametrów z funkcjami view.
Podsumowanie
Kod w tym artykule to dowód słuszności koncepcji (proof of concept), a jego celem jest ułatwienie zrozumienia pomysłu. W przypadku systemu gotowego do produkcji możesz chcieć zaimplementować dodatkową funkcjonalność:
-
Obsługa wartości, które nie są
uint256. Na przykład ciągi znaków (strings). -
Zamiast globalnej pamięci podręcznej, być może 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 opierają się na algorytmie „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. Kolejny około milion wartości to trzy bajty itd. System produkcyjny powinien prowadzić liczniki użycia wpisów w pamięci podręcznej i reorganizować je tak, aby szesnaście najczęstszych wartości zajmowało jeden bajt, kolejne 4080 najczęstszych wartości dwa bajty itd.
Jednak jest to potencjalnie niebezpieczna operacja. Wyobraź sobie następującą sekwencję zdarzeń:
-
Naiwny Noam wywołuje
encodeVal, aby zakodować adres, na który chce wysłać tokeny. Ten adres jest jednym z pierwszych użytych w aplikacji, więc zakodowana wartość to 0x06. Jest to funkcjaview, a nie transakcja, więc odbywa się to między Noamem a węzłem, z którego korzysta, i nikt inny o tym nie wie. -
Właściciel Owen uruchamia operację zmiany kolejności w 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.
-
Naiwny Noam wysyła swoje tokeny na 0x06. Trafiają one na adres
0x0000000000000000000000000de0b6b3a7640000, a ponieważ nikt nie zna klucza prywatnego dla tego adresu, po prostu tam utknęły. Noam nie jest zadowolony.
Istnieją sposoby na rozwiązanie tego problemu, a także powiązanego problemu transakcji, które znajdują się w mempoolu podczas zmiany kolejności w pamięci podręcznej, ale musisz być tego świadomy.
-
Zademonstrowałem tutaj buforowanie z Optimism, ponieważ jestem pracownikiem Optimism i jest to rollup, który znam najlepiej. Ale powinno to działać z każdym rollupem, który pobiera minimalne opłaty za wewnętrzne przetwarzanie, tak że w porównaniu z tym zapisywanie danych transakcji na L1 jest głównym wydatkiem.