Na wieki wieków – czyli Eternal Storage

Przemek/ 4 kwietnia, 2023

“Nic nie trwa wiecznie. Niebezpiecznie jest wierzyć w to, że coś trwa wiecznie.” Te słowa zaczerpnięte z dawnej piosenki Sidneya Polaka są bardzo trafne w kontekście świata, w którym żyjemy. Ale również w odniesieniu do blockchaina są bardzo prawdziwe. Dlatego dzisiaj wracamy do tematu aktualizowania smart kontraktów i przeanalizujemy rozwiązanie zwane Eternal Storage.

Nowe szaty króla

W poprzedniej części został omówiony ogólny problem związany z potrzebą aktualizowania smart kontraktów, jak również dwa rozwiązania: parametryzacja oraz migracja. Serdecznie polecam zapoznać się najpierw z tamtym wpisem, a następnie wrócić do obecnego. Jeżeli masz już to za sobą, to spieszę z wyjaśnieniem kolejnej metody.

Rozwiązanie Eternal Storage jest relatywnie proste, jeżeli chodzi o jego koncepcje. Gdyby całość miała zostać ubrana w jedno zdanie, to można by powiedzieć, że jest to sposób, w którym tworzymy jeden kontrakt do przechowywania danych, a następnie przy pomocy drugiego zapisujemy tam niezbędne informacje. W przypadku chęci zaktualizowania logiki, tworzymy nowy smart kontrakt i z jego pomocą zaczynamy zapisywać dane do naszej “centralnej bazy”. Takie podejście może wyglądać bardzo podobnie do tego, które występuje w przypadku tworzenia tradycyjnych aplikacji, czyli jedna baza danych, do której odwołują się kolejne wersje stworzonej aplikacji. Oczywiście w tym przypadku zarządzanie m.in. użytkownikami jest zdecydowanie łatwiejsze niż w oprogramowaniu opartym o blockchain, ale sama koncepcja aktualizacji jest dosyć podobna.

Co ważne, metoda ta nie wymaga żadnego przenoszenia danych, jak miało to miejsce w przypadku migracji. Również nie musimy w jej obrębie stosować tzw. “assembly”, które będą niezbędne np. przy użyciu rozwiązania zwanego proxy. Jeżeli w tej chwili nie masz pojęcia, czym są te dwa terminy, to nie przejmuj się, w przyszłości wszystko wytłumaczę:)

Niestety metoda ta ma również pewnego rodzaju niedogodności. Tak samo jak w przypadku migracji, tak i tutaj, budująć nową logikę, musimy wgrać nowy smart kontrakt na blockchain, co wiąże się z nowym adresem na blockchainie. To natomiast wymaga od nas przekonania wszystkich użytkowników do integracji z nim. I znowu – nie będzie to problem w naszej aplikacji, w której udostępniamy możliwość korzystania z funkcjonalności kontraktu, ale zawsze należy pamiętać, że nie tylko my możemy stworzyć takie połączenie. Dlatego niezmiernie ważna jest jasna komunikacja z użytkownikami.

Kolejnym minusem, dotyczącym tym razem samych programistów, jest nieco trudniejsza metoda zapisu, odczytu i ogólnego zarządzania wszystkimi danymi, znajdującymi się w smart kontrakcie. Zaraz przejdziemy do kodu i zobaczysz dokładnie co mam na myśli. Nie jest to może “rocket science”, ale może, szczególnie na początku, przysporzyć pewnych kłopotów.

Ostatnią rzeczą, o której warto wspomnieć to to, że metoda to nie działa domyślnie z istniejącymi kontraktami. Oznacza to, że nie da się po prostu wziąć np. standardowego tokena ERC-20 i bez żadnych zmian podpiąć do niego Eternal Storage. Wymagać to będzie zaimplementowania funkcji od nowa, ze względu na wcześniej wspomniany inny model dostępu do danych. Nie jest to może ogromny problem, jednak przez to, stworzenie różnego rozwiązań, będzie wymagało więcej pracy.

Zagrajmy w grę

Rysunek 2. Jigsaw

Żeby dobrze zrozumieć mechanizmy kryjące się za Eternal Storage stworzymy bardzo prosty mechanizm, przy pomocy którego gracze będą powiększać wspólną pulę punktów. Żeby nie zaciemniać kodu i skupić się jedynie na dzisiejszej metodzie, pominiemy wszystkie mechanizmy związane z kontrolą dostępu i innymi mechanizmami bezpieczeństwa. Najpierw zaczniemy od kontraktu, który posłuży nam za nasz Eternal Storage.

contract EternalStorage {

   mapping(bytes32 => uint) uIntStorage;
   mapping(bytes32 => bool) booleanStorage;

   function getUIntValue(bytes32 _key) public view returns (uint){
       return uIntStorage[_key];
   }

   function setUIntValue(bytes32 _key, uint _value) public
   {
       uIntStorage[_key] = _value;
   }

    function getBooleanValue(bytes32 _key) public view returns (bool){
       return booleanStorage[_key];
   }

   function setBooleanValue(bytes32 _key, bool _value) public
   {
       booleanStorage[_key] = _value;
   }
}

Jak widzisz kontrakt nie jest ani długi, ani skomplikowany. Posiada on dwa mapowania, których celem jest przechowywania różnorakich wartości. W naszym przypadku skorzystamy z liczb oraz typu logicznego. Jeżeli logika wymagałaby innego rodzaju danych, wtedy w tym miejscu powinny znaleźć się mapowania im odpowiadające. 

W kontrakcie znajdziesz jeszcze cztery metody, dwie służące do zapisu wartości oraz dwie do odczytu. Tak stworzony kontrakt posłuży nam za wcześniej wspomnią “centralą bazę” dla naszych danych.

Zanim przejdziemy do kodu reprezentującego grę, musimy stworzyć jeszcze jeden kontrakt, w zasadzie bibliotekę, która będzie pewnego rodzaju pomostem między Eternal Storage a implementacją logiki. W przypadku tak prostego rozwiązania jak nasze, możnaby się bez niej obejść, jednak w przypadku bardziej skomplikowanego kodu, zalecane jest jej użycie. W ten sposób nie będziemy musieli za każdym razem implementować od początku całego mechanizmu odpowiedzialnego za dostęp do danych. Jeżeli nigdy pojęcie biblioteki w Solidity nie obiło Ci się o uszy, nie przejmuj się i potraktuj to po prostu jako dodatkowy smart kontrakt, o specjalnych właściwościach, których znajomość w tej chwili nie jest istotna.

library GameLib {

   function getPoints(address _eternalStorage) public view returns (uint256)  {
       return EternalStorage(_eternalStorage).getUIntValue(keccak256("points"));
   }

   function setPoints(address _eternalStorage, uint _points) public {
       EternalStorage(_eternalStorage).setUIntValue(keccak256("points"), _points);
   }
}

Utworzona biblioteka posiada jedynie dwie metody, które operować będą na na naszej “centralnej bazie”. Na pierwszy rzut oka wydawać się to może nieco zagmatwane, ale po spokojnym przyjrzeniu się, logika powinna być łatwo zrozumiała. Ponieważ w naszym kontrakcie EternalStorage mamy mapowanie przechowujące liczby, to dodając coś do niego musimy zapisać to pod odpowiednim kluczem. W naszym przypadku będzie to wartość ”points”. Analogicznie działa odczytywanie danych z mapowania. Ponieważ kluczem są bajty, dlatego nasz wyraz przepuszczamy przez funkcję hashującą. Czas przejść do implementacji gry.

contract Game {
   using GameLib for address;
   address immutable eternalStorage;

   constructor(address _eternalStorage) {
       eternalStorage = _eternalStorage;
   }

   function getPoints() public view returns(uint) {
       return eternalStorage.getPoints();
   }

   function play() public {
       eternalStorage.setPoints(eternalStorage.getPoints() + 1);
   }
}

Jeżeli nie miałeś do czynienia z bibliotekami, to pierwsza linijka kontraktu może nic Ci nie mówić. Najprościej mówiąc, przy jej pomocy rozszerzamy właściwości zmiennych typu “address”. Innymi słowy, od teraz na każdym adresie będziemy mogli wywoływać metody z biblioteki. Przyda nam się to do obsługi punktów w Eternal Storage’u.

Kontrakt zawiera jedynie dwie metody. Pierwsza z nich służy do pozyskania informacji, ile punktów zostało zdobytych, druga zwiększa ich ilość o jeden. Obecna implementacja sprawia, że każda osoba, która wywoła funkcję “play”, przyczyni się do podniesienia wyniku, niezależnie od tego, ile razy to zrobi. Teraz przyszedł czas na aktualizację.

Załóżmy, że chcielibyśmy, aby dowolny gracz mógł jedynie raz zagrać w grę. Jak wspomniałem wcześniej, kontrakt odpowiedzialny za Eternal Storage zostanie bez zmian. Naszym zadaniem jest jedynie zaktualizować logikę gry oraz w tym przypadku bibliotekę.

library GameLib {

   function getPoints(address _eternalStorage) public view returns (uint256)  {
       return EternalStorage(_eternalStorage).getUIntValue(keccak256("points"));
   }

   function setPoints(address _eternalStorage, uint _points) public {
       EternalStorage(_eternalStorage).setUIntValue(keccak256("points"), _points);
   }

       function getUserHasPlayed(address _eternalStorage) public view returns(bool) {
       return EternalStorage(_eternalStorage).getBooleanValue(keccak256(abi.encodePacked("played",msg.sender)));
   }

   function setUserHasPlayed(address _eternalStorage) public {
       EternalStorage(_eternalStorage).setBooleanValue(keccak256(abi.encodePacked("played",msg.sender)), true);
   }
}

Tym razem w naszej bibliotece znalazły się jeszcze dwie metody. Pierwsza z nich posłuży do zapisania faktu, że dany gracz wziął już udział w grze, drugi do sprawdzenia, czy tak faktycznie jest. Jak widzisz, tym razem operujemy na drugim mapowaniu. Reszta pozostaje bez zmian. Czas na zaktualizowany kontrakt gry.

error Game__AlreadyPlayed(address player);

contract Game {
   using GameLib for address;
   address immutable eternalStorage;

   constructor(address _eternalStorage) {
       eternalStorage = _eternalStorage;
   }

   function getPoints() public view returns(uint) {
       return eternalStorage.getPoints();
   }

   function play() public {
       if(eternalStorage.getUserHasPlayed()) {
           revert Game__AlreadyPlayed(msg.sender);
       }
       eternalStorage.setUserHasPlayed();
       eternalStorage.setPoints(eternalStorage.getPoints() + 1);
   }
}

Nowy kontrakt nie różni się znacząco, ale tym razem funkcja “play” pozwala wyłącznie na jej jednokrotne wywołanie przez pojedynczego gracza. Teraz wystarczy wgrać kontrakt na blockchain i nowa wersja gry będzie dostępna dla naszych użytkowników.

W przypadku naszej implementacji pojawia się natomiast jeden “mały” problem, który może już przyszedł Ci do głowy. Ponieważ stara wersja gry wciąż istnieje na blockchainie, to nic nie stoi na przeszkodzie, aby ktokolwiek dalej dodawał punkty z jej wykorzystaniem. Tak jak mówiłem, w tym przypadku chodziło wyłącznie o pokazanie mechanizmu, a nie przejmowanie się każdym innym detalem. Oczywiście w produkcyjnej implementacji prawdopodobnie chcielibyśmy uniknąć takiej sytuacji, dlatego należałoby również dodać mechanizmy blokujące użycie starego smart kontraktu. Już nie wspominając o tym, że obecnie dowolna osoba może bezpośrednio wywołać funkcję na kontrakcie Eternal Storage i zmienić całkowicie wynik:).

Jak widzisz, używając tej metody nie musimy faktycznie martwić się o migrację danych z poprzedniej wersji aplikacji, ponieważ żadna informacja nie zmienia swojego miejsca. Jedyne co moglibyśmy chcieć zrobić, to “naprawić” wpisy w przypadku wystąpienia błędu i zapisu nieoczekiwanych wyników. W zależności od tego,  jak podejdziemy do implementacji, może to być jak najbardziej wykonalne.

Warto również pamiętać, aby nasz kontrakt reprezentujący Eternal Storage posiadał wszystkie niezbędne mapowania. Gdybyśmy w przedstawionym przykładzie chcieli dodać rejestrację imion graczy, to niestety nasza “baza danych” nie zezwala na takie zachowanie i wrócilibyśmy tym sposobem do metody migracji. Dlatego lepiej dodać na początku mapowania dla większej ilości typów, mimo że będzie to nieco droższe podczas deploymentu.

Mam nadzieję, że udało mi się wytłumaczyć Ci w zrozumiały sposób kolejny mechanizm, służący do aktualizacji smart kontraktów. Nie jest on jednak idealny i posiada swoje ograniczenia. Na szczęście istnieją jeszcze inne rozwiązania, a kolejnym, którym się zajmiemy w niedługim czasie będzie tzw. proxy. Do zobaczenia:)!

Share this Post
Subscribe
Powiadom o
guest
0 komentarzy
Inline Feedbacks
View all comments