Wyrocznia w praktyce – ulepszamy loterię

Przemek/ 1 sierpnia, 2022

Wyrocznia w praktyce – ulepszamy loterię

Ostatnie dwa wpisy poruszały temat blockchain oracles, ale tylko z perspektywy teorii. Dlatego dzisiaj wrócimy do pisania kodu i zastosujemy wyrocznię w praktyce. W tym poście pokażę Ci, jak skorzystać z Chainlink VRF. Dodamy ten mechanizm do loterii, którą stworzyliśmy w przeszłości, aby uniemożliwić manipulację wyboru zwycięzcy.

Jeżeli nie miałeś okazji zapoznać się z tematami dotyczącymi wyroczni, polecam to zrobić przed przystąpieniem do czytania tego posta. Wpisy znajdziesz pod tym i tym adresem.

Konfiguracja

Rysunek 1. Konfiguracja
(źródło: https://medium.com/ballerina-techblog/configuration-management-in-ballerina-b952cae80c49)

Zanim przejdziemy do samego kodu, zróbmy najpierw kilka rzeczy. Po pierwsz, zasilmy swój portfel testowymi tokenami LINK. Będą one niezbędne do pokrycia opłaty za wygenerowanie liczby. Po drugie musimy utworzyć subskrypcję, która będzie “wpychać” losowość do naszego kontraktu, ponieważ będziemy korzystać z Chainlink VRF V2.

Zasilenie portfela przebiega w analogiczny sposób, jak robiliśmy to w przeszłości. Wchodzimy pod ten adres, podpinamy swójego Metamaska i klikamy przycisk “Send request”. Upewnij się tylko, że w Metamasku wybrałeś sieć Rinkeby. 

Prawdopodobnie nie widzisz jeszcze tokenów LINK w aplikacji Metamask. Jest tak, ponieważ portfel nie potrafi sam wykryć, jakie krypto posiadasz na swoim adresie i dlatego musimy skonfigurować to ręcznie. Można to zrobić z poziomu Metamaska podając blockchainowy adres kontraktu powiązanego z tokenem. Istnieje również łatwiejszy sposób, czyli wchodzimy na tę stronę, znajdujemy sekcję “Rinkeby” i korzystamy z przycisku “Add to wallet”. Musimy jeszcze zatwierdzić operację w Metamasku i od tego momentu tokeny LINK powinny być widoczne w Twojej aplikacji.

Teraz możemy przejść do stworzenia subskrypcji. Należy wejść pod ten adres oraz podpiąć swój portfel. Jeżeli w międzyczasie nic nie zmieniałeś w Metamasku, to strona automatycznie wykryje sieć Rinkeby. Należy nacisnąć przycisk “Create subscription”, w kolejnym oknie zrobić to samo, a następnie zatwierdzić transakcję. Po zakończeniu operacji, możemy zasilić naszą subskrypcję tokenami LINK. Przelej posiadane LINKI i zatwierdź transakcję. W kolejnym kroku zostaniesz poproszony o podanie kontraktu, który będzie używał subskrypcję. Pomiń ten krok przy pomocy przycisku “I’ll do it later”. Zajmiemy się tym później, ponieważ “consumer” to adres naszej loterii, którą dopiero stworzymy.

Została nam jeszcze jedna rzecz. Dzisiejszą loterię będziemy testować wyłącznie na sieci Rinkeby, dlatego przydałoby się utworzyć z 2-3 konta w Metamasku, które będą mogły kupować losy. Robimy to z poziomu aplikacji, klikając w awatar swojego konta a następnie w przycisk “Create Account”. Pamiętaj, aby następnie zasilić je etherami. Możesz to zrobić ponownie poprzez Chainlink Faucet, albo po prostu roześlij wcześniej pozyskany ether między różne konta.
Konfigurację mamy za sobą, dlatego możemy przejść teraz do crème de la crème tego wpisu, czyli zakodowania poprawionej loterii:)

Kodujemy

Na początek przypomnijmy sobie, co takiego chcemy zbudować. Zasady są następujące:

  1. Loteria rozpoczyna się w trakcie wgrania smart kontraktu na blockchain.
  2. W kontrakcie zdefiniowany jest czas trwania loterii.
  3. Aby wziąć udział w loterii, użytkownik musi kupić bilet, którego cena jest niezmienna i zdefiniowana w smart kontrakcie.
  4. Płatność za bilet dokonywana będzie przy pomocy etheru.
  5. Użytkownik może kupić dowolną liczbę biletów.
  6. Po upływie czasu trwania loterii, dowolny użytkownik może wyłonić zwycięzcę, a ten automatycznie otrzyma w nagrodę wszystkie zgromadzone środki.

Teraz czas na kod całego smart kontraktu. Poniżej niego znajdziesz dokładne omówienie zmian, jakie zaszły w tej iteracji. Implementacja powstała zgodnie z dokumentacją VRF, dlatego warto do niej zerknąć, na wypadek gdyby coś było nie do końca zrozumiałe.

//SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
 
import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
 
error LotteryV2__LotteryEnded();
error LotteryV2__LotteryInProgress();
error LotteryV2__WrongTicketPurchase(address player, uint256 amount);
error LotteryV2_DrawStarted();
error LotteryV2__TransferFailed();
 
contract LotteryV2 is VRFConsumerBaseV2 {
   event TicketPurchased(address who);
   event RandomNumberRequested();
   event WinnerPicked(address who, uint256 reward);
 
   uint16 private constant REQUEST_CONFIRMATIONS = 3;
   uint32 private constant NUM_WORDS = 1;
 
   VRFCoordinatorV2Interface private immutable i_vrfCoordinator;
   uint32 private immutable i_callbckGasLimit;
   uint64 private immutable i_subscriptionId;
   bytes32 private immutable i_keyHash;
 
   uint256 private immutable i_ticketPrice;
   uint256 private immutable i_endBlock;
 
   bool private s_drawStarted;
   address private s_winner;
   uint256 private s_number;
   address[] private s_players;
 
   constructor(
       address _vrfCoordinator,
       bytes32 _keyHash,
       uint64 _subscriptionId,
       uint32 _callbackGasLimit,
       uint256 _ticketPrice,
       uint256 _lotteryDurationInBlocks
   ) VRFConsumerBaseV2(_vrfCoordinator) {
       i_vrfCoordinator = VRFCoordinatorV2Interface(_vrfCoordinator);
       i_keyHash = _keyHash;
       i_subscriptionId = _subscriptionId;
       i_callbckGasLimit = _callbackGasLimit;
       i_ticketPrice = _ticketPrice;
       i_endBlock = block.number + _lotteryDurationInBlocks;
   }
 
   function buyTicket() external payable {
       if (block.number > i_endBlock) {
           revert LotteryV2__LotteryEnded();
       }
       if (msg.value != i_ticketPrice) {
           revert LotteryV2__WrongTicketPurchase(msg.sender, msg.value);
       }
 
       s_players.push(msg.sender);
 
       emit TicketPurchased(msg.sender);
   }
 
   function pickWinner() external {
       if (block.number <= i_endBlock) {
           revert LotteryV2__LotteryInProgress();
       }
       if(s_drawStarted) {
           revert LotteryV2_DrawStarted();
       }
 
       i_vrfCoordinator.requestRandomWords(
           i_keyHash,
           i_subscriptionId,
           REQUEST_CONFIRMATIONS,
           i_callbckGasLimit,
           NUM_WORDS
       );
 
       emit RandomNumberRequested();
   }
 
   function fulfillRandomWords(
       uint256, /*requestId*/
       uint256[] memory randomWords
   ) internal override {
       uint256 winnerIndex = randomWords[0] % s_players.length;
       address payable winner = payable(s_players[winnerIndex]);
       uint256 reward = address(this).balance;
 
       s_number = randomWords[0];
       s_winner = winner;
 
       (bool sucess, ) = winner.call{value: reward}("");
 
       if (!sucess) {
           revert LotteryV2__TransferFailed();
       }
 
       emit WinnerPicked(winner, reward);
   }
 
   function getTicketPrice() external view returns (uint256) {
       return i_ticketPrice;
   }
 
   function getEndBlock() external view returns (uint256) {
       return i_endBlock;
   }
 
   function getPlayers() external view returns (address[] memory) {
       return s_players;
   }
 
   function getNumber() external view returns (uint256) {
       return s_number;
   }
 
   function getWinner() external view returns (address) {
       return s_winner;
   }
}

Ponieważ musimy użyć mechanizmów Chainlinka, dlatego na początku importujemy dwa dodatkowe smart kontrakty. Pierwszy z nich to interfejs, który pozwoli nam wnioskować o losową liczbę. Drugi, to kontrakt, który musi być rozszerzony przez naszą implementację, co zrobione jest kilka linijek niżej. Jest to niezbędne, żeby Chainlink mógł dostarczyć nam wygenerowaną liczbę. Przed deklaracją ciała kontraktu znajdują się jeszcze definicje pięciu błędów, które będą wykorzystane później.

W sekcji eventów doszedł jeden nowy o nazwie “RandomNumberRequested”. Zostanie on użyty w trakcie generowania losowej liczby. Kolejno znajdują się dwie stałe oraz kilka nowych pól, których wartości wykorzystamy później. Żeby ułatwić przeglądanie wyników po zakończeniu loterii, kontrakt będzie przechowywał również informację o wygenerowanej wartości oraz wybranym zwycięzcy. 

Kolejna rzecz, która uległa zmianie to konstruktor. Obecnie przyjmuje parametry, które pozwolą ustawić wartości poszczególnych pól. Również podanie kosztu biletu oraz czasu trwania loterii zostało sparametryzowane, co sprawia, że nasz kontrakt jest nieco bardziej elastyczny. 

Jak możesz zauważyć, odeszliśmy od odmierzania długości loterii w sekundach, a będziemy to teraz robić w liczbie bloków. W tym miejscu zapamiętaj jedynie, że jest to lepsza metoda, ponieważ czas bloku może być w pewien sposób kontrolowany przez górników, natomiast jego numer już nie. Jeżeli z jakiś względów chciałbyś poznać przybliżoną liczbę sekund, jaka przeznaczona jest na kupowanie biletów, wystarczy że pomnożysz liczbę bloków przez 15 sekund, czyli średni czas dołączania nowego bloku.

Dalej znajduje się funkcja odpowiedzialna za zakup biletów. Jedyna rzecz, która się w niej zmieniła, to sposób w jaki sprawdzamy poszczególne warunki. Poprzednio używaliśmy słowa kluczowego “require”. Teraz robimy to nieco inaczej i wykorzystamy do tego wcześniej utworzone definicje błędów. Takie podejście sprawi, że koszt wywołania funkcji będzie mniejszy. Nie mieliśmy okazji jeszcze porozmawiać o opłatach na Ethereum oraz o tym, czym jest gas i gasPrice, ale teraz nie zaprzątaj sobie zbytnio tym głowy:)

Kolejna część naszej loterii to wybór zwycięzcy. Tym razem proces ten odbywać się będzie w dwóch krokach. Po pierwsze musimy wysłać zapytanie z prośbą o losową liczbę. Posłuży nam do tego funkcja “pickWinner”. Na jej początku sprawdzamy, czy loteria dobiegła końca. Jeśli tak, to używamy chainlinkowego kontraktu do utworzenia zapytania. 

W tej chwili przyszedł czas na omówienia użytych parametrów:

  • i_keyHash – jest to hash reprezentujący maksymalną cenę, jaką jesteśmy w stanie zapłacić za wykonanie tej operacji. Koszt wyrażony jest w WEI (najmniejsza jednostka dla etheru)
  • i_subscriptionId – id stworzonej przez nas wcześniej subskrypcji
  • REQUEST_CONFIRMATIONS – minimalna liczba bloków, które muszą zostać dołączone, zanim wyrocznia odpowie
  • i_callbckGasLimit – maksymalna ilość gazu, który może zostać użyty do wywołania funkcji, która przyjmuje losową liczbę (fulfillRandomWords)
  • NUM_WORDS – liczba oczekiwanych losowych wyników

Na końcu generujemy zdarzenie, informujące o rozpoczęciu procesu generowania liczby. Teraz czas przejść do kolejnej funkcji jaką jest “fulfillRandomWords”. Ponieważ jest to metoda pochodząca z kontraktu “VRFConsumerBaseV2”, musi posiadać dokładnie taką sygnaturę, jaka znajduje się w kontrakcie. Jako parametr funkcja ta przyjmuje “requestId”, które w tym wpisie nie będzie nas interesować, oraz tablicę losowych liczb. Metoda wywoływana jest automatycznie w momencie dostarczenia wyników.

Kod tej funkcji jest bliźniaczo podobny do metody “pickWinner” z poprzedniej loterii, z tą różnicą, że nie generujemy sami losowej liczby, a korzystamy z tej dostarczonej przez Chainlinka. Przy jej użyciu zapisujemy również dodatkowe wartości w kontrakcie. 

Dodatkowo na samym początku, sprawdzamy, czy czasem ktoś już nie wywołał tej metody w przeszłości. Poprzednim razem nie stanowiło to problemu, ponieważ jeśli nawet tak by się stało, to nie miałoby to wpływu na przebieg losowania. Tym razem, mogłoby się okazać, że drugie wywołanie funkcji dostarczyło liczbę do kontraktu szybciej, niż to pierwsze. Teoretycznie nie sprawia to wielkiego problemu, ponieważ wszystko opiera się o losowość pochodzącą z Chainlinka i ostatecznie jakiś zwycięzca zostałby wybrany w sprawiedliwy sposób. Jednak chcielibyśmy, aby zawsze tylko pierwsze wywołanie miało znaczenie.

Ostatnia część kontraktu, to funkcje umożliwiające pobranie poszczególnych danych. Kontrakt jest już gotowy i omówiony, dlatego możemy przejść do wgrania kodu na blockchain.

Deploymentu nadszedł czas

Tym razem od razu użyjemy sieci testowej Rinkeby. Musimy to zrobić, dlatego że używamy kontraktów i subskrypcji od Chainlinka, które nie istnieją na JavaScriptowej imitacji blockchaina.

W Remixie wchodzimy w zakładkę “Deploy & run transactions” i jako “ENVIRONMENT” wybieramy “Injected Web3”. Ustawiamy w “CONTRACT” naszą loterię, a następnie podajemy wszystkie niezbędne parametry. W naszym przypadku będą to wartości:

  • _VRFCOORDINATOR = 0x6168499c0cFfCaCD319c818142124B7A15E857ab
  • _KEYHASH = 0xd89b2bf150e3b9e13446986e571fb9cab24b13cea0a43ea20a6049a85cc807cc
  • _CALLBACKGASLIMIT = 500000
  • _SUBSCRIPTIONID = id Twojej subskrypcji (znajdziesz je na stronie, gdzie stworzyłeś chainlinkową subsrypcję)
  • _TICKETPRICE = 10000000000000000 (0.01 ETH)
  • _LOTTERYDURATIONINBLOCKS = 40 (około 10 minut, co powinno wystarczyć na spokojne przetestowanie aplikacji)
Rysunek 2. Deployment

Po pomyślnym wgraniu kontaktu, kopiujemy jego adres i udajemy się na stronę naszej subskrypcji. Tam, przy pomocy przyciski “Add consumer”, dodajemy naszą loterię. 

Rysunek 3. Chainlink Subscription

Po zakończeniu transakcji, wracamy do Remix’a i przy pomocy różnych kont dokonujemy zakupu kilku biletów. Aby zmienić użytkownika, należy przełączyć go w Metamasku. Pamiętaj o podaniu odpowiedniej wartości w polu “VALUE” (10000000000000000 Wei).

Po około 10 minutach powinieneś być w stanie rozpocząć proces wyboru zwycięzcy. Jeżeli chcesz upewnić się, jaki numer ma ostatnio wydobyty blok i czy jest większy niż ten, mówiący o zakończeniu loterii, możesz udać się na stronę https://rinkeby.etherscan.io/ i tam sprawdzić, jaki blok został ostatnio wydobyty.

Po wywołaniu funkcji “pickWinner” będziesz musiał poczekać kilka bloków, zanim ostatecznie zostanie wybrany zwycięzca. Pola “number” i “winner” zostaną wypełnione danymi dopiero wtedy, kiedy wyrocznia wywoła funkcję “fulfillRandomWords”. Na stronie swojej subskrypcji, również możesz śledzić, czy ta akcja już się zakończyła.

Rysunek 4. Losowanie

Gdy proces dobiegnie końca, zwycięzca otrzyma nagrodę i odpowiednie dane zostaną zapisane w smart kontrakcie. Możemy teraz sprawdzić jaką liczbę dostarczył nam Chainlink oraz adres szczęściarza, który wygrał ether.

Rysunek 5. Wygrany

Ostatni krok

Na sam koniec przydałoby się jeszcze zweryfikować kontrakt na Etherscanie. Cały proces przebiega podobnie jak ostatnio. Tym razem musimy jednak wprowadzić jeszcze wartości parametrów, które przyjmował nasz konstruktor. Niestety nie możemy ich podać w takiej formie, w jakiej zapisywaliśmy je w Remixie. Parametry muszą zostać zakodowane do innej postaci. Można to zrobić na kilka sposobów, ale ja polecam użycie aplikacji https://abi.hashex.org/

Wystarczy, że po wejściu na stronę skorzystamy z sekcji “Or enter your parameters manually” i wprowadzimy odpowiednie wartości. Następnie kopiujemy otrzymany wynik i wracamy do Remix’a.

Rysunek 6. Parametry

W zakładce “Etherscan – Contract verification” wprowadzamy wymagane dane i weryfikujemy kontrakt. Jeżeli nie masz możliwości otworzenia okna wtyczki, to prawdopodobnie musisz ją ponownie aktywować.

Rysunek 7. Weryfikacja

Po chwili na Etherscanie powinien pojawić się czytelny tekst. Może rzucić Ci się w oczy, że widoczny jest nie tylko kod Twojego kontrakt, ale również “VRFConsumerBaseV2” oraz “VRFCoordinatorV2Interface”. Dzieje się tak, ponieważ loteria je wykorzystuje, a tym samym ich kod jest “wstrzykiwany” do naszego pliku. Dlatego całość prezentuje się jak na poniższym obrazku.

Rysunek 8. Etherscan

Dzisiaj znowu odwaliliśmy razem kawał dobrej roboty! Co prawda, nasza aplikacja nie jest jeszcze idealna. Moglibyśmy nieco bardziej zautomatyzować proces wybierania zwycięzcy i idealnie do tego nadałby się Chainlink Keepers. Kiedyś się tym na pewno zajmiemy. 

Teraz już nie tylko wiesz, czym są i jak działają wyrocznie w teorii, ale również potrafisz skorzystać z jednej z nich w praktyce. Wiedza ta pomoże Ci w przyszłości tworzyć lepsze i bezpieczniejsze smart kontrakty. Tymczasem widzimy się w kolejnym poście, gdzie poruszę gorący temat ostatnich miesięcy, czyli NFT i Metaverse:)

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