U prošlom smo dijelu ovog serijala programiranja na Ethereum blockchainu počeli izgradnju dokazivo poštene tombole. U ovom dijelu ćemo ispolirati kod i dovršiti projekt.

Radi boljeg razumijevanja i lakšeg praćenja sadržaja, preporučamo da pročitate prvi dio prije čitanja ovog članka i da svoj kod dovedete do stadija u kojem se nalazi na kraju prijašnjeg dijela.

Izvlačenje dobitnika

Krenimo u izradnju funkcije koja će biti zaslužna za odabir pobjednika: draw. Ako znamo da trebamo izvući ukupno dva dobitnika u tomboli, definirajmo odgovornosti te funkcije:

  • funkcija treba postati dostupna tek nakon određenog vremena kako bi tombola potrajala dovoljno da skupi sudionike
  • funkcija treba odabrati 2 dobitnika
  • funkcija treba onemogućiti sudjelovanje nakon odabira tih pobjednika
  • nakon što odabere pobjednike, funkcija treba poslati skupljeni ether na adresu koju smo definirali u prvom dijelu

Počnimo s okvirom te funkcije:

function draw() external {

}

Funkciju smo deklarirali eksternom jer ćemo je aktivirati isključivo izvana. Ne postoji funkcija u samom ugovoru koja bi morala moći pokrenuti funkciju draw, pa je preciznije definirati je kao external.

Vremensko ograničenje

Prvi uvjet je da funkcija postane dostupna tek nakon određenog vremena. Dodajmo sljedeću liniju u funkciju draw:

require (now > 1522908000);

Unix epoch je moment koji se smatra početkom mjerenja vremena na računalima: 1.1.1970.

now (ili njegov ekvivalent block.timestamp) je timestamp, tj. broj sekundi od unix epocha kada je trenutni block izrudaren. Budući da se Ethereum blokovi rudare u prosjeku svakih 15 sekundi, now neće uvijek točno odgovarati trenutnoj sekundi od unix epocha, već će od bloka do bloka imati skokove od 15ak sekundi.

Broj s kojim uspoređujemo now je broj sekundi od unix epocha koji želimo da prođe prije nego draw funkcija postane dostupna. Da iz običnog ljudima čitkog datuma dobijete timestamp možete koristiti konverter poput ovoga.

Alternativa je spremiti vrijeme kreiranja ugovora u novu varijablu npr. startTime, izračunati razliku tog vremena i now, i izraziti razliku u nativnim jedinicama Solidity jezika, ovako:

require (now - startTime > 2 weeks);

Obje solucije su jednako pravilne, no u ovom slučaju držati ćemo se prve.

U našem originalnom rješenju ciljamo na datum 5.4. za kraj tombole.

Izbor pobjednika

Sljedeći dio koda koji ćemo napisati unutar funkcije draw izgleda ovako:

uint256 winningIndex = randomGen();
address winner = players[winningIndex];
winners.push(winner);

Varijable koje deklariramo unutar neke funkcije, poput ovih unutar funkcije draw, zovu se lokalne (umjesto globalne) jer su dostupne samo unutar te funkcije – druge funkcije ne trebaju pristup tim varijablama. Ako neke varijable nemaju upotrebu izvan funkcije u kojima ih se koristi, bolje je držati ih lokalnima jer to dovodi do manjeg troška gas-a.

Varijabla winningIndex je vrste (tipa) uint256. To je skraćeno za unsigned 256bit integer. Unsigned znači “bez predznaka”, što znači da brojevi koje možemo spremiti u ovu varijablu mogu biti samo pozitivni. 256bit znači koja je maksimalna veličina broja koji možemo spremiti. integer je cijeli broj, npr. 1, 2, 4053, 2384759826738947289. Drugim riječima, u varijablu winningIndex spremiti ćemo redni broj dobitnog sudionika kojeg ćemo dobiti pozivanjem funkcije randomGen(). Tu ćemo funkciju definirati kasnije.

address winner je Ethereum adresa pobjednika koji je odabran prije dobivenim nasumičnim brojem. Nju izvlačimo iz liste adresa sudionika tako da između uglatih zagrada nakon imena liste smjestimo taj redni broj: players[winningIndex]. Da objasnimo to na banalnijem primjeru: ako imamo sljedeću listu string a[] s string elementima foo, bar, baz, ovako:

string a[];
a.push('foo');
a.push('bar');
a.push('baz');

… onda je a[2] isto što i baz, jer brojanje u listama počinje od 0. a[0] je foo, i tako dalje. Na isti način iz liste players vadimo igrača pod rednim brojem winningIndex. winningIndex će u players[winningIndex] biti zamijenjen konkretnim brojem kod izvršenja programa, pa će primjerice ako nasumično izvučeni broj bude 3 to izgledati ovako: players[3], rezultirajući izvlačenjem četvrtog igrača iz liste.

Konačno, pobjednika bilježimo u listu pobjednika s winners.push(winner).

No, da osiguramo da pobjednik ne može biti i drugi puta slučajno izvučen, moramo ga odstraniti iz liste igrača:

players[winningIndex] = players[players.length - 1];
players.length--;

Svaki element neke liste može se obrisati ovako:

delete players[winningIndex];

No, to nije adekvatno rješenje jer će na tom mjestu ostati rupa. Ako uzmemo za primjer našu listu a i na njoj izvršimo delete a[1], dobiti ćemo ovakvu listu:

a[0] = "foo";
a[1] = null;
a[2] = "baz";

Iako je a[1] nepostojeća vrijednost null, da iz ove liste izvučemo validnu vrijednost trebali bismo više puta pokušavati izvlačiti dobitnika u stilu “ako izvučeni dobitnik nije validna vrijednost, probaj ponovno”. To bi bilo vrlo neefikasno rješenje jer bi dodatno izvršavanje pokušaja uzrokovalo veći trošak transakcije. Ovisno o broju pokušaja, transakcija bi možda bila i odbijena ukoliko bi postala preskupa.

Stoga prvo uzimamo zadnji element iz liste igrača pomoću players[players.length - 1] (length je svojstvo koje svaka lista ima, i predstavlja broj elemenata u listi. Koristimo -1 jer brojanje elemenata počinje od 0) i spremamo ga na poziciju izvučenog dobitnika s:

players[winningIndex] = players[players.length - 1];

U primjeru naše liste a, to je ekvivalent sljedećem:

a[1] = a[a.length - 1];

//

a[0] = "foo";
a[1] = "baz";
a[2] = "baz";

No, sada u listi imamo dva ista elementa. Spriječili smo prošlog pobjednika da pobijedi još jednom, no sada drugi igrač ima dvostruku šansu za dobitak. Da popravimo stvar, kratimo listu s:

players.length--;

Skraćivanje duljine liste automatski briše zadnji element pa time – ako to primjenimo na našu listu a – izgleda ovako:

a.length--;

//

a[0] = "foo";
a[1] = "baz";

Sljedeći put kada pozovemo funkciju draw naš dobitnik više ne može biti izvučen.

Završavanje tombole

Spomenuli smo da želimo dva dobitnika jer darujemo više ulaznica. Stoga, moramo pozvati draw funkciju dva puta. Budući da smo se osigurali od duplog izvlačenja iste osobe, sada se moramo pobrinuti za gašenje tombole nakon 2 izvlačenja i slanje skupljenog ethera na našu adresu za dobrotvorne svrhe.

Na kraj funkcije draw dodati ćemo sljedeći kod:

if (winners.length == 2) {
    charity.transfer(address(this).balance);
}

Svaka Ethereum adresa (tip varijable address) ima funkciju transfer. Ta funkcija prima jedan argument – količinu Wei-a koje treba poslati na adresu čija se transfer funkcija poziva. U ovom slučaju budući da šaljemo sredstva na adresu koju smo definirali u charity varijabli, zovemo njenu transfer funkciju.

Argument koji šaljemo je address(this).balance. this se odnosi na “ovo”, tj. “ovaj pametni ugovor u kojem se cijela logika odigrava”. Svaka Ethereum adresa u Soliditiju osim transfer ima i svojstvo balance što označava količinu Wei-a koje ta adresa trenutno sadrži. No, da pročitamo balans adrese na kojoj leži naš ugovor (this), prvo moramo saznati na kojoj adresi leži, a to radimo pomoću address(this).

Drugim riječima, address(this).balance znači “količina Wei-a na adresi OVOG ugovora” a cijeli dio koda koji smo napisali u ovom dijelu kaže “ako je broj pobjednika 2, prenesi sav novac ovog pametnog ugovora na charity adresu”.

Sada smo prenijeli sredstva kada je broj pobjednika 2, no kako ćemo spriječiti ponovno pozivanje draw funkcije?

Tako da dodamo novi require na vrh funkcije:

require (winners.length < 2);

Ovo sprječava izvršavanje funkcije draw ako je izvučeno 2 ili više igrača. Radi sigurnosti, možemo dodati isti taj uvjet i u play funkciju da onemogućimo slanje novca u tombolu ako je tombola završena. Cijeli naš kod do sada napisan izgleda ovako:

pragma solidity ^0.4.20;

contract Blocksplit {
    
    address[] public players;
    mapping (address => bool) public uniquePlayers;
    address[] public winners;
    
    address public charity = 0xc39eA9DB33F510407D2C77b06157c3Ae57247c2A;
    
    function() external payable {
        play(msg.sender);
    }
    
    function play(address _participant) payable public {
        require (winners.length < 2);        
        require (msg.value >= 1000000000000000 && msg.value <= 100000000000000000);
        require (uniquePlayers[_participant] == false);
        
        players.push(_participant);
        uniquePlayers[_participant] = true;
    }
    
    function draw() external {
        
        require (now > 1522908000);
        require (winners.length < 2);
        
        uint256 winningIndex = randomGen();
        address winner = players[winningIndex];
        winners.push(winner);
        
        players[winningIndex] = players[players.length - 1];
        players.length--;
        
        if (winners.length == 2) {
            charity.transfer(address(this).balance);
        }
    }
    
}

Odabir nasumičnog broja

Ostao nam je još samo jedan ali najbitniji dio: randomGen funkcija za nasumični odabir pobjednika.

Ako ste pročitali naš uvod u Ethereum znate da se radi o svjetskom računalu koje svoj kod izvršava identično na više tisuća čvorova. Sustav je deterministički, što znači da ne smije biti devijacije u rezultatima od stroja do stroja - u protivnom bismo imali tisuće različitih Ethereum lanaca. Bitno je da rezultat na svakom stroju za svaku izvršenu matematičku informaciju bude isti. Pa kako onda do nasumičnog broja?

Moramo naći način da dobijemo broj koji je nepredvidljiv i nasumičan ali opet identičan kod svih sudionika Ethereum mreže. Dobar kandidat za to je upravo blockhash - skup brojki i slova proizveden zbrajanjem i posebnim interpretiranjem (hashiranjem) sadržaja cijelog Ethereum bloka. Budući da nitko ne zna koji će blockhash biti sljedeći (da zna mogao bi uvijek osvojiti block reward i kontrolirati blockchain), njegova vrijednost je dovoljni izvor nasumičnosti koji će biti identičan na računalu svakog sudionika. Kod kojim biramo nasumični broj je dakle sljedeći:

function randomGen() constant internal returns (uint256 randomNumber) {
        uint256 seed = uint256(block.blockhash(block.number - 200));
        return(uint256(keccak256(block.blockhash(block.number-1), seed )) % players.length);
    }

Funkcija randomGen je deklarirana kao constant, što znači da ne mijenja vrijednost blockchaina već samo čita iz njega. internal govori da funkcija može biti pozvana samo iz tombole, ne i izvana. returns (uint256) znači da funkcija vraća vrijednost svom pozivatelju, i to vrijednost tipa uint256. Prisjetimo se - deklarirali smo winningIndex varijablu kao isti taj tip - tipovi moraju odgovarati, inače dobivena vrijednost neće biti spremiva u varijablu.

U tijelu funkcije prvo deklariramo seed. U informatici kad god je potrebno dobiti neki nasumični broj potrebno je računati ga prema nekom seedu - sjemenu. Seed je unosna vrijednost prema kojoj se definira izlazna. To može biti broj kapi kiše trenutno na prozoru, broj pojedenih jabuka prošle godine, spoj svih srednjih imena rodbine s očeve strane ili, kao u ovom slučaju, blockhash dvjestotog bloka od trenutnog.

uint256 seed = uint256(block.blockhash(block.number - 200));

Ovo znači "U varijablu seed tipa uint256 spremi unit256 verziju blockhasha 200 blokova starog bloka". Ono "unit256 verziju" je bitno jer je blockhash heksadecimalna vrijednost, izgleda ovako 0xab49276f782.... Da bismo dobili decimalni (broj na bazi 10, tj. integer) broj iz toga, pozivamo funkciju uint256() s blockhashom kao parametrom. Ovo se inače naziva casting - "castamo" vrijednost nekog tipa u vrijednost drugog tipa. Seed se obično šalje u funkciju a ne generira u njoj, jer predvidljivost seed-a znači i predvidljivost ishoda. U ovom slučaju to je manje bitno.

Napomena: Za primjer unošenja seeda u funkciju za generiranje nasumičnog broja pogledajte princip na kojem funkcionira generiranje Bitcoin adrese. Vaše micanje miša ili unošenje vrijednosti u polje je zapravo "generiranje seeda".

Zatim, vraćamo vrijednost iz funkcije pozivatelju:

return(uint256(keccak256(block.blockhash(block.number-1), seed )) % players.length);

Iako ovo zvuči komplicirano, zapravo i nije. Redom:

  • return() se poziva kada treba vratiti vrijednost pozivatelju. U ovom slučaju pozivatelj je varijabla winningIndex iz funkcije draw. U zagrade ide vrijednost koju vraćamo.
  • uint256() pretvara bilo koju vrijednost u zagradama u cijeli, decimalni broj.
  • keccak256 je kvalitetnija verzija sha3 algoritma za hashiranje. Više o tome saznajte u Uvodu u blockchain. Funkcija keccak256() može primiti više vrijednosti odvojenih zarezom i izračunava hash iz svih tih vrijednosti spojenih zajedno. U ovom slučaju spaja prije izračunati seed i predzadnji blockhash.
  • block.blockhash() je funkcija koja, ako joj se da redni broj bloka, vraća blockhash tog bloka. Ova funkcija sprema samo zadnjih 255 blokova, pa stoga ako zatražite block.blockhash(block.number-256) dobiti ćete 0, kao i ako zatražite bilo koji stariji block od toga (257, 258...). U ovom slučaju tražimo predzadnji block: block.number-1.
  • % players.length znači "modulo duljine liste igrača". Drugim riječima, "podijeli taj dobiveni broj s brojem igrača, i vrati ostatak". Zašto ostatak? Jer znamo da ostatak garantirano mora biti manji od broja igrača, a to je upravo ono što tražimo - naš broj winningIndex MORA imati potencijal imati vrijednost između 0 i player.length-1 da bi pokrio cijelu listu igrača.

Napomena: Gore opisani proces generiranja nasumičnih vrijednosti NIJE SIGURAN za vrijednosti iznad block nagrade (trenutno 5 Eth) jer rudari mogu utjecati na ishod ako im se više isplati nego dobivanje 5 Eth za nagradu. Ovakav je pristup prikladan samo za trivijalno kockanje - nasumičnost s većim ulozima pokriti ćemo u budućim člancima!

Imamo još jedan problem. Ako nam nasumičnost ovisi o blockhashu, tada će izvlačenje 2 dobitnika jednog za drugim rezultirati istim "nasumičnim" brojem - ako obje transakcije uđu u isti block, obje će izabrati isti broj. Da bi se to spriječilo, koristimo trik odgode. Prvo deklariramo globalnu varijablu uint256 drawnBlock = 0;. Zatim prilikom izvlačenja provjerimo da drawnBlock nije isti kao što i broj bloka koji je upravo izvučen: require (block.number != drawnBlock);. Nakon što potvrdimo da nije, spremamo broj trenutnog bloka u tu varijablu, čime osiguravamo da sljedeće izvlačenje dobitnika ne uspijeva ako se vrši u istom bloku, ali uspijeva već u sljedećem.

Cjelokupni kod našeg pametnog ugovora sada izgleda ovako:

pragma solidity ^0.4.20;

contract Blocksplit {
    
    address[] public players;
    mapping (address => bool) public uniquePlayers;
    address[] public winners;
    
    address public charity = 0xc39eA9DB33F510407D2C77b06157c3Ae57247c2A;
    
    uint256 drawnBlock = 0;
    
    function() external payable {
        play(msg.sender);
    }
    
    function play(address _participant) payable public {
        require (winners.length < 2);        
        require (msg.value >= 1000000000000000 && msg.value <= 100000000000000000);
        require (uniquePlayers[_participant] == false);
        
        players.push(_participant);
        uniquePlayers[_participant] = true;
    }
    
    function draw() external {
        require (now > 1522908000);
        require (block.number != drawnBlock);
        require (winners.length < 2);
        
        drawnBlock = block.number;
        
        uint256 winningIndex = randomGen();
        address winner = players[winningIndex];
        winners.push(winner);
        
        players[winningIndex] = players[players.length - 1];
        players.length--;
        
        if (winners.length == 2) {
            charity.transfer(address(this).balance);
        }
    }
    
    function randomGen() constant internal returns (uint256 randomNumber) {
        uint256 seed = uint256(block.blockhash(block.number - 200));
        return(uint256(keccak256(block.blockhash(block.number-1), seed )) % players.length);
    }
    
}

Automatsko izvlačenje

Bitno je napomenuti da kod blockchaina nema mogućnosti da se transakcija - i time pokretanje izbora pobjednika - izvrši sama.

Budući da je draw funkcija funkcija koja mijenja stanje blockchaina, ona zahtijeva da se pošalje transackija. Slanje transakcije zahtijeva plaćanje gas troškova, pa time nije moguće unaprijed platiti dovoljno ethera da se transakcija izvrši jer ta vrijednost nije fiksna.

Drugim riječima, izvršenje transakcije na Ethereum blockchainu mora biti manualno pokrenuto, pa će tako i proces odabira dobitnika u tomboli morati biti ručno pokrenut.

Postoje neke solucije koje pokušavaju riješiti problem planiranih samo-izvršavajućih transakcija, poput Ethereum Alarm Clock, no o njima ćemo neki drugi put.

Zaključak

Graditi dokazivo poštene kockarnice na blockchainu je zbog otvorene prirode blockchaina i matematičke sigurnosti njegovih sastavnih dijelova gotovo trivijalno ali i vrlo opasno ukoliko neke od poznatih problema nemamo na umu prilikom razvoja. U ovom smo primjeru to vidjeli na običnoj tomboli, no isti su principi lako primjenjivi na kasino, rulet, lutriju, i druge igre na sreću.

Izvorni kod ovog projekta možete u potpunosti preuzeti ovdje.

U sljedećem ćemo članku pokriti neka alternativna rješenja i raspraviti probleme oko rješenja koja smo koristili u ovom članku. Specifično, pričati ćemo više o nasumičnosti na blockchainu i sigurnom spremanju privatnih podataka na javnom blockchainu (npr. kako spremiti kupone za tombolu na blockchain ali tako da ih samo pobjednici mogu pročitati?).

U odvojenom članku pokriti ćemo i lansiranje ovog ugovora na živu Ethereum mrežu, nakon ćega ćete s lakoćom moći lansirati vlastite tombole uz minimalne preinake.


Ako vam je ovaj članak koristio, razmislite o tome da nas podržite u daljnjem radu donacijom.

LEAVE A REPLY

Please enter your comment!
Please enter your name here