Przejdź do głównej treści

Wykorzystanie wiedzy zerowej do tajnego stanu

serwer
pozałańcuchowy
scentralizowany
wiedza zerowa
zokrates
mud
prywatność
Zaawansowany
Ori Pomerantz
15 marca 2025
25 minut czytania

Na blockchainie nie ma tajemnic. Wszystko, co jest publikowane na blockchainie, jest otwarte dla każdego do odczytu. Jest to konieczne, ponieważ blockchain opiera się na tym, że każdy może go zweryfikować. Jednak gry często opierają się na tajnym stanie. Na przykład gra w sapera (opens in a new tab) nie ma absolutnie żadnego sensu, jeśli możesz po prostu wejść w eksplorator bloków i zobaczyć mapę.

Najprostszym rozwiązaniem jest użycie komponentu serwerowego do przechowywania tajnego stanu. Jednak powodem, dla którego używamy blockchaina, jest zapobieganie oszustwom ze strony twórcy gry. Musimy zapewnić uczciwość komponentu serwerowego. Serwer może dostarczyć hash stanu i użyć dowodów z wiedzą zerową, aby udowodnić, że stan użyty do obliczenia wyniku ruchu jest prawidłowy.

Po przeczytaniu tego artykułu będziesz wiedzieć, jak stworzyć tego rodzaju serwer przechowujący tajny stan, klienta do wyświetlania stanu oraz komponent onchain do komunikacji między nimi. Główne narzędzia, których użyjemy, to:

NarzędzieCelZweryfikowano na wersji
Zokrates (opens in a new tab)Dowody z wiedzą zerową i ich weryfikacja1.1.9
TypeScript (opens in a new tab)Język programowania zarówno dla serwera, jak i klienta5.4.2
Node (opens in a new tab)Uruchamianie serwera20.18.2
Viem (opens in a new tab)Komunikacja z blockchainem2.9.20
MUD (opens in a new tab)Zarządzanie danymi onchain2.0.12
React (opens in a new tab)Interfejs użytkownika klienta18.2.0
Vite (opens in a new tab)Serwowanie kodu klienta4.2.1

Przykład Minesweeper

Minesweeper (Saper) (opens in a new tab) to gra, która zawiera tajną mapę z polem minowym. Gracz decyduje się kopać w określonym miejscu. Jeśli w tym miejscu znajduje się mina, gra się kończy. W przeciwnym razie gracz otrzymuje informację o liczbie min na ośmiu polach otaczających to miejsce.

Ta aplikacja została napisana przy użyciu MUD (opens in a new tab), frameworka, który pozwala nam przechowywać dane onchain za pomocą bazy danych klucz-wartość (opens in a new tab) i automatycznie synchronizować te dane z komponentami pozałańcuchowymi. Oprócz synchronizacji, MUD ułatwia zapewnienie kontroli dostępu, a innym użytkownikom rozszerzanie (opens in a new tab) naszej aplikacji w sposób niewymagający zezwolenia.

Uruchamianie przykładu Minesweeper

Aby uruchomić przykład Minesweeper:

  1. Upewnij się, że masz zainstalowane wymagane oprogramowanie (opens in a new tab): Node (opens in a new tab), Foundry (opens in a new tab), git (opens in a new tab), pnpm (opens in a new tab) oraz mprocs (opens in a new tab).

  2. Sklonuj repozytorium.

    git clone https://github.com/qbzzt/20240901-secret-state.git
    
  3. Zainstaluj pakiety.

    cd 20240901-secret-state/
    pnpm install
    npm install -g mprocs
    

    Jeśli Foundry zostało zainstalowane jako część pnpm install, musisz zrestartować powłokę wiersza poleceń.

  4. Skompiluj kontrakty

    cd packages/contracts
    forge build
    cd ../..
    
  5. Uruchom program (w tym blockchain anvil (opens in a new tab)) i poczekaj.

    mprocs
    

    Zauważ, że uruchamianie trwa długo. Aby zobaczyć postęp, najpierw użyj strzałki w dół, aby przewinąć do zakładki contracts, gdzie zobaczysz wdrażane kontrakty MUD. Kiedy pojawi się wiadomość Waiting for file changes…, kontrakty są wdrożone, a dalszy postęp będzie widoczny w zakładce server. Tam poczekaj, aż pojawi się wiadomość Verifier address: 0x.....

    Jeśli ten krok zakończy się pomyślnie, zobaczysz ekran mprocs z różnymi procesami po lewej stronie i wyjściem konsoli dla aktualnie wybranego procesu po prawej stronie.

    The mprocs screen

    Jeśli wystąpi problem z mprocs, możesz uruchomić cztery procesy ręcznie, każdy we własnym oknie wiersza poleceń:

    • Anvil

      cd packages/contracts
      anvil --base-fee 0 --block-time 2
      
    • Contracts

      cd packages/contracts
      pnpm mud dev-contracts --rpc http://127.0.0.1:8545
      
    • Server

      cd packages/server
      pnpm start
      
    • Client

      cd packages/client
      pnpm run dev
      
  6. Teraz możesz przejść do klienta (opens in a new tab), kliknąć New Game i zacząć grać.

Tabele

Potrzebujemy kilku tabel (opens in a new tab) onchain.

  • Configuration: Ta tabela to singleton, nie ma klucza i zawiera pojedynczy rekord. Służy do przechowywania informacji o konfiguracji gry:

    • height: Wysokość pola minowego
    • width: Szerokość pola minowego
    • numberOfBombs: Liczba bomb na każdym polu minowym
  • VerifierAddress: Ta tabela to również singleton. Służy do przechowywania jednej części konfiguracji, adresu kontraktu weryfikatora (verifier). Moglibyśmy umieścić te informacje w tabeli Configuration, ale są one ustawiane przez inny komponent, serwer, więc łatwiej jest umieścić je w osobnej tabeli.

  • PlayerGame: Kluczem jest adres gracza. Dane to:

    • gameId: 32-bajtowa wartość, która jest hashem mapy, na której gra gracz (identyfikator gry).
    • win: wartość logiczna określająca, czy gracz wygrał grę.
    • lose: wartość logiczna określająca, czy gracz przegrał grę.
    • digNumber: liczba udanych wykopów w grze.
  • GamePlayer: Ta tabela przechowuje odwrotne mapowanie, z gameId na adres gracza.

  • Map: Kluczem jest krotka trzech wartości:

    • gameId: 32-bajtowa wartość, która jest hashem mapy, na której gra gracz (identyfikator gry).
    • współrzędna x
    • współrzędna y

    Wartością jest pojedyncza liczba. Wynosi 255, jeśli wykryto bombę. W przeciwnym razie jest to liczba bomb wokół tej lokalizacji plus jeden. Nie możemy po prostu użyć liczby bomb, ponieważ domyślnie cała pamięć w EVM i wszystkie wartości wierszy w MUD wynoszą zero. Musimy odróżnić sytuację „gracz jeszcze tu nie kopał” od „gracz tu kopał i odkrył, że wokół nie ma żadnych bomb”.

Ponadto komunikacja między klientem a serwerem odbywa się za pośrednictwem komponentu onchain. Jest to również zaimplementowane przy użyciu tabel.

  • PendingGame: Nieobsłużone żądania rozpoczęcia nowej gry.
  • PendingDig: Nieobsłużone żądania kopania w określonym miejscu w określonej grze. Jest to tabela pozałańcuchowa (opens in a new tab), co oznacza, że nie jest zapisywana w pamięci EVM, a jedynie odczytywana pozałańcuchowo przy użyciu zdarzeń.

Przepływy wykonywania i danych

Te przepływy koordynują wykonywanie między klientem, komponentem onchain i serwerem.

Inicjalizacja

Kiedy uruchamiasz mprocs, następują te kroki:

  1. mprocs (opens in a new tab) uruchamia cztery komponenty:

  2. Pakiet contracts wdraża kontrakty MUD, a następnie uruchamia skrypt PostDeploy.s.sol (opens in a new tab). Ten skrypt ustawia konfigurację. Kod z GitHuba określa pole minowe 10x5 z ośmioma minami (opens in a new tab).

  3. Serwer (opens in a new tab) rozpoczyna od konfiguracji MUD (opens in a new tab). Aktywuje to między innymi synchronizację danych, dzięki czemu kopia odpowiednich tabel istnieje w pamięci serwera.

  4. Serwer subskrybuje funkcję, która ma zostać wykonana, gdy zmieni się tabela Configuration (opens in a new tab). Ta funkcja (opens in a new tab) jest wywoływana po wykonaniu PostDeploy.s.sol i modyfikacji tabeli.

  5. Gdy funkcja inicjująca serwer ma już konfigurację, wywołuje zkFunctions (opens in a new tab), aby zainicjować część serwera związaną z wiedzą zerową. Nie może się to wydarzyć, dopóki nie otrzymamy konfiguracji, ponieważ funkcje z wiedzą zerową muszą mieć szerokość i wysokość pola minowego jako stałe.

  6. Po zainicjowaniu części serwera związanej z wiedzą zerową, następnym krokiem jest wdrożenie kontraktu weryfikacji z wiedzą zerową na blockchain (opens in a new tab) i ustawienie adresu weryfikatora w MUD.

  7. Na koniec subskrybujemy aktualizacje, aby widzieć, kiedy gracz zażąda rozpoczęcia nowej gry (opens in a new tab) lub kopania w istniejącej grze (opens in a new tab).

Nowa gra

Oto co się dzieje, gdy gracz żąda nowej gry.

  1. Jeśli dla tego gracza nie ma trwającej gry lub istnieje gra, ale z gameId równym zero, klient wyświetla przycisk nowej gry (opens in a new tab). Gdy użytkownik naciśnie ten przycisk, React uruchamia funkcję newGame (opens in a new tab).

  2. newGame (opens in a new tab) to wywołanie System. W MUD wszystkie wywołania są kierowane przez kontrakt World, a w większości przypadków wywołujesz <namespace>__<function name>. W tym przypadku wywołanie dotyczy app__newGame, które MUD następnie kieruje do newGame w GameSystem (opens in a new tab).

  3. Funkcja onchain sprawdza, czy gracz nie ma trwającej gry, a jeśli jej nie ma, dodaje żądanie do tabeli PendingGame (opens in a new tab).

  4. Serwer wykrywa zmianę w PendingGame i uruchamia zasubskrybowaną funkcję (opens in a new tab). Ta funkcja wywołuje newGame (opens in a new tab), która z kolei wywołuje createGame (opens in a new tab).

  5. Pierwszą rzeczą, którą robi createGame, jest utworzenie losowej mapy z odpowiednią liczbą min (opens in a new tab). Następnie wywołuje makeMapBorders (opens in a new tab), aby utworzyć mapę z pustymi krawędziami, co jest niezbędne dla Zokrates. Na koniec createGame wywołuje calculateMapHash, aby uzyskać hash mapy, który jest używany jako identyfikator gry.

  6. Funkcja newGame dodaje nową grę do gamesInProgress.

  7. Ostatnią rzeczą, którą robi serwer, jest wywołanie app__newGameResponse (opens in a new tab), które jest onchain. Ta funkcja znajduje się w innym System, ServerSystem (opens in a new tab), aby umożliwić kontrolę dostępu. Kontrola dostępu jest zdefiniowana w pliku konfiguracyjnym MUD (opens in a new tab), mud.config.ts (opens in a new tab).

    Lista dostępu pozwala tylko jednemu adresowi na wywołanie System. Ogranicza to dostęp do funkcji serwera do pojedynczego adresu, więc nikt nie może podszywać się pod serwer.

  8. Komponent onchain aktualizuje odpowiednie tabele:

    • Tworzy grę w PlayerGame.
    • Ustawia odwrotne mapowanie w GamePlayer.
    • Usuwa żądanie z PendingGame.
  9. Serwer identyfikuje zmianę w PendingGame, ale nic nie robi, ponieważ wantsGame (opens in a new tab) ma wartość false.

  10. W kliencie gameRecord (opens in a new tab) jest ustawione na wpis PlayerGame dla adresu gracza. Kiedy PlayerGame się zmienia, gameRecord również ulega zmianie.

  11. Jeśli w gameRecord znajduje się wartość, a gra nie została wygrana ani przegrana, klient wyświetla mapę (opens in a new tab).

Kopanie

  1. Gracz klika przycisk komórki mapy (opens in a new tab), co wywołuje funkcję dig (opens in a new tab). Ta funkcja wywołuje dig onchain (opens in a new tab).

  2. Komponent onchain przeprowadza szereg podstawowych testów poprawności (sanity checks) (opens in a new tab), a jeśli zakończą się one pomyślnie, dodaje żądanie kopania do PendingDig (opens in a new tab).

  3. Serwer wykrywa zmianę w PendingDig (opens in a new tab). Jeśli jest ona prawidłowa (opens in a new tab), wywołuje kod z wiedzą zerową (opens in a new tab) (wyjaśniony poniżej), aby wygenerować zarówno wynik, jak i dowód jego poprawności.

  4. Serwer (opens in a new tab) wywołuje digResponse (opens in a new tab) onchain.

  5. digResponse robi dwie rzeczy. Najpierw sprawdza dowód z wiedzą zerową (opens in a new tab). Następnie, jeśli dowód jest poprawny, wywołuje processDigResult (opens in a new tab), aby faktycznie przetworzyć wynik.

  6. processDigResult sprawdza, czy gra została przegrana (opens in a new tab) lub wygrana (opens in a new tab), i aktualizuje Map, mapę onchain (opens in a new tab).

  7. Klient automatycznie odbiera aktualizacje i aktualizuje mapę wyświetlaną graczowi (opens in a new tab), a w stosownych przypadkach informuje gracza o wygranej lub przegranej.

Korzystanie z Zokrates

W przepływach opisanych powyżej pominęliśmy części związane z wiedzą zerową, traktując je jako czarną skrzynkę. Teraz otwórzmy ją i zobaczmy, jak napisany jest ten kod.

Haszowanie mapy

Możemy użyć tego kodu JavaScript (opens in a new tab), aby zaimplementować Poseidon (opens in a new tab), funkcję skrótu Zokrates, której używamy. Jednak chociaż byłoby to szybsze, byłoby również bardziej skomplikowane niż po prostu użycie funkcji skrótu Zokrates do tego celu. To jest samouczek, więc kod jest zoptymalizowany pod kątem prostoty, a nie wydajności. Dlatego potrzebujemy dwóch różnych programów Zokrates: jednego do samego obliczenia hasha mapy (hash) i drugiego do faktycznego utworzenia dowodu z wiedzą zerową dla wyniku kopania w danej lokalizacji na mapie (dig).

Funkcja skrótu

To jest funkcja, która oblicza hash mapy. Przeanalizujemy ten kod linijka po linijce.

import "hashes/poseidon/poseidon.zok" as poseidon;
import "utils/pack/bool/pack128.zok" as pack128;

Te dwie linie importują dwie funkcje z biblioteki standardowej Zokrates (opens in a new tab). Pierwsza funkcja (opens in a new tab) to hash Poseidon (opens in a new tab). Przyjmuje ona tablicę elementów field (opens in a new tab) i zwraca field.

Element pola w Zokrates ma zazwyczaj mniej niż 256 bitów długości, ale niewiele mniej. Aby uprościć kod, ograniczamy mapę do maksymalnie 512 bitów i haszujemy tablicę czterech pól, a w każdym polu używamy tylko 128 bitów. Funkcja pack128 (opens in a new tab) zamienia w tym celu tablicę 128 bitów na field.

def hashMap(bool[${width+2}][${height+2}] map) -> field {

Ta linia rozpoczyna definicję funkcji. hashMap przyjmuje pojedynczy parametr o nazwie map, dwuwymiarową tablicę typu bool(ean). Rozmiar mapy to width+2 na height+2 z powodów, które wyjaśniono poniżej.

Możemy użyć ${width+2} i ${height+2}, ponieważ programy Zokrates są przechowywane w tej aplikacji jako ciągi znaków szablonu (template strings) (opens in a new tab). Kod między ${ a } jest ewaluowany przez JavaScript, dzięki czemu program może być używany dla różnych rozmiarów map. Parametr mapy ma dookoła obramowanie o szerokości jednej lokalizacji bez żadnych bomb, co jest powodem, dla którego musimy dodać dwa do szerokości i wysokości.

Wartością zwracaną jest field, które zawiera hash.

bool[512] mut map1d = [false; 512];

Mapa jest dwuwymiarowa. Jednak funkcja pack128 nie działa z tablicami dwuwymiarowymi. Dlatego najpierw spłaszczamy mapę do 512-bajtowej tablicy, używając map1d. Domyślnie zmienne Zokrates są stałymi, ale musimy przypisać wartości do tej tablicy w pętli, więc definiujemy ją jako mut (opens in a new tab).

Musimy zainicjować tablicę, ponieważ Zokrates nie ma undefined. Wyrażenie [false; 512] oznacza tablicę 512 wartości false (opens in a new tab).

u32 mut counter = 0;

Potrzebujemy również licznika, aby odróżnić bity, które już wypełniliśmy w map1d, od tych, których jeszcze nie wypełniliśmy.

for u32 x in 0..${width+2} {

W ten sposób deklaruje się pętlę for (opens in a new tab) w Zokrates. Pętla for w Zokrates musi mieć stałe granice, ponieważ chociaż wydaje się być pętlą, kompilator w rzeczywistości ją „rozwija”. Wyrażenie ${width+2} jest stałą w czasie kompilacji, ponieważ width jest ustawiane przez kod TypeScript przed wywołaniem kompilatora.

for u32 y in 0..${height+2} {
         map1d[counter] = map[x][y];
         counter = counter+1;
      }
   }

Dla każdej lokalizacji na mapie umieść tę wartość w tablicy map1d i zwiększ licznik.

field[4] hashMe = [
        pack128(map1d[0..128]),
        pack128(map1d[128..256]),
        pack128(map1d[256..384]),
        pack128(map1d[384..512])
    ];

Używamy pack128, aby utworzyć tablicę czterech wartości field z map1d. W Zokrates array[a..b] oznacza wycinek tablicy, który zaczyna się od a i kończy na b-1.

return poseidon(hashMe);
}

Użyj poseidon, aby przekonwertować tę tablicę na hash.

Program haszujący

Serwer musi wywołać hashMap bezpośrednio, aby utworzyć identyfikatory gry. Jednak Zokrates może wywołać tylko funkcję main w programie, aby go uruchomić, więc tworzymy program z funkcją main, która wywołuje funkcję skrótu.

${hashFragment}

def main(bool[${width+2}][${height+2}] map) -> field {
    return hashMap(map);
}

Program kopania

To jest serce części aplikacji opartej na wiedzy zerowej, gdzie generujemy dowody używane do weryfikacji wyników kopania.

${hashFragment}

// Liczba min w lokalizacji (x,y)
def map2mineCount(bool[${width+2}][${height+2}] map, u32 x, u32 y) -> u8 {
   return if map[x+1][y+1] { 1 } else { 0 };
}

Dlaczego obramowanie mapy

Dowody z wiedzą zerową wykorzystują obwody arytmetyczne (opens in a new tab), które nie mają prostego odpowiednika dla instrukcji if. Zamiast tego używają odpowiednika operatora warunkowego (opens in a new tab). Jeśli a może wynosić zero lub jeden, możesz obliczyć if a { b } else { c } jako ab+(1-a)c.

Z tego powodu instrukcja if w Zokrates zawsze ewaluuje obie gałęzie. Na przykład, jeśli masz taki kod:

bool[5] arr = [false; 5];
u32 index=10;
return if index>4 { 0 } else { arr[index] }

Zwróci on błąd, ponieważ musi obliczyć arr[10], mimo że ta wartość zostanie później pomnożona przez zero.

To jest powód, dla którego potrzebujemy obramowania o szerokości jednej lokalizacji dookoła mapy. Musimy obliczyć całkowitą liczbę min wokół danej lokalizacji, a to oznacza, że musimy widzieć lokalizację jeden wiersz powyżej i poniżej, po lewej i po prawej stronie od miejsca, w którym kopiemy. Oznacza to, że te lokalizacje muszą istnieć w tablicy mapy dostarczonej do Zokrates.

def main(private bool[${width+2}][${height+2}] map, u32 x, u32 y) -> (field, u8) {

Domyślnie dowody Zokrates zawierają swoje dane wejściowe. Wiedza o tym, że wokół danego miejsca znajduje się pięć min, na nic się nie zda, jeśli nie wiesz, które to miejsce (i nie możesz po prostu dopasować tego do swojego żądania, ponieważ wtedy prover mógłby użyć innych wartości i ci o tym nie powiedzieć). Musimy jednak utrzymać mapę w tajemnicy, jednocześnie dostarczając ją do Zokrates. Rozwiązaniem jest użycie parametru private, który nie jest ujawniany przez dowód.

Otwiera to kolejną drogę do nadużyć. Prover mógłby użyć poprawnych współrzędnych, ale utworzyć mapę z dowolną liczbą min wokół lokalizacji, a być może w samej lokalizacji. Aby zapobiec temu nadużyciu, sprawiamy, że dowód z wiedzą zerową zawiera hash mapy, który jest identyfikatorem gry.

return (hashMap(map),

Wartością zwracaną jest tutaj krotka (tuple), która zawiera tablicę hasha mapy, a także wynik kopania.

if map2mineCount(map, x, y) > 0 { 0xFF } else {

Używamy 255 jako wartości specjalnej w przypadku, gdy w samej lokalizacji znajduje się bomba.

map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
            map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
            map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
         }
   );
}

Jeśli gracz nie trafił na minę, dodaj liczbę min dla obszaru wokół lokalizacji i zwróć ją.

Używanie Zokrates z TypeScript

Zokrates posiada interfejs wiersza poleceń, ale w tym programie używamy go w kodzie TypeScript (opens in a new tab).

Biblioteka zawierająca definicje Zokrates nazywa się zero-knowledge.ts (opens in a new tab).

import { initialize as zokratesInitialize } from "zokrates-js"

Zaimportuj wiązania JavaScript dla Zokrates (opens in a new tab). Potrzebujemy tylko funkcji initialize (opens in a new tab), ponieważ zwraca ona obietnicę (promise), która rozwiązuje się do wszystkich definicji Zokrates.

export const zkFunctions = async (width: number, height: number) : Promise<any> => {

Podobnie jak sam Zokrates, eksportujemy również tylko jedną funkcję, która również jest asynchroniczna (opens in a new tab). Kiedy w końcu zwróci wynik, udostępnia kilka funkcji, jak zobaczymy poniżej.

const zokrates = await zokratesInitialize()

Zainicjuj Zokrates, pobierz wszystko, czego potrzebujemy z biblioteki.

Następnie mamy funkcję skrótu i dwa programy Zokrates, które widzieliśmy powyżej.

const digCompiled = zokrates.compile(digProgram)
const hashCompiled = zokrates.compile(hashProgram)

Tutaj kompilujemy te programy.

// Utwórz klucze do weryfikacji z wiedzą zerową.
// W systemie produkcyjnym należałoby użyć ceremonii konfiguracji.
// (https://zokrates.github.io/toolbox/trusted_setup.html#initializing-a-phase-2-ceremony).
const keySetupResults = zokrates.setup(digCompiled.program, "")
const verifierKey = keySetupResults.vk
const proverKey = keySetupResults.pk

W systemie produkcyjnym moglibyśmy użyć bardziej skomplikowanej ceremonii konfiguracji (setup ceremony) (opens in a new tab), ale to wystarczy do celów demonstracyjnych. To nie problem, że użytkownicy mogą znać klucz provera – nadal nie mogą go użyć do udowodnienia rzeczy, chyba że są one prawdziwe. Ponieważ określamy entropię (drugi parametr, ""), wyniki zawsze będą takie same.

Uwaga: Kompilacja programów Zokrates i tworzenie kluczy to powolne procesy. Nie ma potrzeby powtarzania ich za każdym razem, a jedynie wtedy, gdy zmienia się rozmiar mapy. W systemie produkcyjnym wykonałbyś je raz, a następnie zapisał wynik. Jedynym powodem, dla którego nie robię tego tutaj, jest chęć zachowania prostoty.

calculateMapHash

const calculateMapHash = function (hashMe: boolean[][]): string {
  return (
    "0x" +
    BigInt(zokrates.computeWitness(hashCompiled, [hashMe]).output.slice(1, -1))
      .toString(16)
      .padStart(64, "0")
  )
}

Funkcja computeWitness (opens in a new tab) faktycznie uruchamia program Zokrates. Zwraca strukturę z dwoma polami: output, które jest wynikiem programu jako ciąg JSON, oraz witness, które zawiera informacje potrzebne do utworzenia dowodu z wiedzą zerową dla wyniku. Tutaj potrzebujemy tylko wyniku.

Wynikiem jest ciąg znaków w postaci "31337", liczba dziesiętna ujęta w cudzysłów. Ale wynik, którego potrzebujemy dla viem, to liczba szesnastkowa w postaci 0x60A7. Używamy więc .slice(1,-1), aby usunąć cudzysłowy, a następnie BigInt, aby przekształcić pozostały ciąg znaków, który jest liczbą dziesiętną, na BigInt (opens in a new tab). .toString(16) konwertuje ten BigInt na ciąg szesnastkowy, a "0x"+ dodaje znacznik dla liczb szesnastkowych.

// Wykop i zwróć dowód z wiedzą zerową wyniku
// (kod po stronie serwera)

Dowód z wiedzą zerową obejmuje publiczne dane wejściowe (x i y) oraz wyniki (hash mapy i liczba bomb).

    const zkDig = function(map: boolean[][], x: number, y: number) : any {
        if (x<0 || x>=width || y<0 || y>=height)
            throw new Error("Trying to dig outside the map")

Sprawdzenie, czy indeks wykracza poza granice w Zokrates, jest problematyczne, więc robimy to tutaj.

const runResults = zokrates.computeWitness(digCompiled, [map, `${x}`, `${y}`])

Wykonaj program kopania.

        const proof = zokrates.generateProof(
            digCompiled.program,
            runResults.witness,
            proverKey)

        return proof
    }

Użyj generateProof (opens in a new tab) i zwróć dowód.

const solidityVerifier = `
        // Map size: ${width} x ${height}
        \n${zokrates.exportSolidityVerifier(verifierKey)}
        `

Weryfikator Solidity, inteligentny kontrakt, który możemy wdrożyć na blockchain i użyć do weryfikacji dowodów wygenerowanych przez digCompiled.program.

    return {
        zkDig,
        calculateMapHash,
        solidityVerifier,
    }
}

Na koniec zwróć wszystko, czego może potrzebować inny kod.

Testy bezpieczeństwa

Testy bezpieczeństwa są ważne, ponieważ błąd funkcjonalności w końcu sam się ujawni. Jeśli jednak aplikacja jest niezabezpieczona, prawdopodobnie pozostanie to w ukryciu przez długi czas, zanim zostanie ujawnione przez kogoś, kto oszukuje i ucieka z zasobami należącymi do innych.

Uprawnienia

W tej grze istnieje jeden uprzywilejowany podmiot – serwer. Jest to jedyny użytkownik, który może wywoływać funkcje w ServerSystem (opens in a new tab). Możemy użyć cast (opens in a new tab), aby zweryfikować, czy wywołania funkcji wymagających zezwolenia są dozwolone tylko dla konta serwera.

Klucz prywatny serwera znajduje się w setupNetwork.ts (opens in a new tab).

  1. Na komputerze, na którym działa anvil (blockchain), ustaw te zmienne środowiskowe.

    WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b
    UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
    AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
    
  2. Użyj cast, aby spróbować ustawić adres weryfikatora jako nieautoryzowany adres.

    cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $UNAUTHORIZED_KEY
    

    Nie tylko cast zgłasza błąd, ale możesz również otworzyć MUD Dev Tools w grze w przeglądarce, kliknąć Tables i wybrać app__VerifierAddress. Zobaczysz, że adres nie jest zerowy.

  3. Ustaw adres weryfikatora jako adres serwera.

    cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $AUTHORIZED_KEY
    

    Adres w app__VerifiedAddress powinien teraz wynosić zero.

Wszystkie funkcje MUD w tym samym System przechodzą przez tę samą kontrolę dostępu, więc uważam ten test za wystarczający. Jeśli uważasz inaczej, możesz sprawdzić inne funkcje w ServerSystem (opens in a new tab).

Nadużycia związane z wiedzą zerową

Matematyka potrzebna do weryfikacji Zokrates wykracza poza zakres tego samouczka (i moje umiejętności). Możemy jednak przeprowadzić różne testy kodu z wiedzą zerową, aby zweryfikować, czy w przypadku nieprawidłowego wykonania zakończy się on niepowodzeniem. Wszystkie te testy będą wymagały od nas zmiany zero-knowledge.ts (opens in a new tab) i ponownego uruchomienia całej aplikacji. Nie wystarczy zrestartować procesu serwera, ponieważ wprowadza to aplikację w niemożliwy stan (gracz ma grę w toku, ale gra nie jest już dostępna dla serwera).

Błędna odpowiedź

Najprostszą możliwością jest podanie błędnej odpowiedzi w dowodzie z wiedzą zerową. Aby to zrobić, wchodzimy do zkDig i modyfikujemy linię 91 (opens in a new tab):

proof.inputs[3] = "0x" + "1".padStart(64, "0")

Oznacza to, że zawsze będziemy twierdzić, że jest jedna bomba, niezależnie od prawidłowej odpowiedzi. Spróbuj zagrać w tę wersję, a na karcie server na ekranie pnpm dev zobaczysz ten błąd:

cause: {
        code: 3,
        message: 'execution reverted: revert: Zero knowledge verification fail',
        data: '0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000
000000000000000000000000000000000000000000000000205a65726f206b6e6f776c6564676520766572696669636174696f6
e206661696c'
      },

Więc tego rodzaju oszustwo się nie udaje.

Błędny dowód

Co się stanie, jeśli podamy prawidłowe informacje, ale po prostu będziemy mieli błędne dane dowodu? Teraz zamień linię 91 na:

proof.proof = {
  a: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
  b: [
    ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
    ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
  ],
  c: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
}

Nadal kończy się to niepowodzeniem, ale teraz bez podania przyczyny, ponieważ dzieje się to podczas wywołania weryfikatora.

Jak użytkownik może zweryfikować kod zero trust?

Inteligentne kontrakty są stosunkowo łatwe do zweryfikowania. Zazwyczaj deweloper publikuje kod źródłowy w eksploratorze bloków, a eksplorator bloków weryfikuje, czy kod źródłowy kompiluje się do kodu w transakcji wdrożenia kontraktu. W przypadku MUD System jest to nieco bardziej skomplikowane (opens in a new tab), ale niewiele.

Jest to trudniejsze w przypadku wiedzy zerowej. Weryfikator zawiera pewne stałe i wykonuje na nich pewne obliczenia. To nie mówi ci, co jest dowodzone.

    function verifyingKey() pure internal returns (VerifyingKey memory vk) {
        vk.alpha = Pairing.G1Point(uint256(0x0f43f4fe7b5c2326fed4ac6ed2f4003ab9ab4ea6f667c2bdd77afb068617ee16), uint256(0x25a77832283f9726935219b5f4678842cda465631e72dbb24708a97ba5d0ce6f));
        vk.beta = Pairing.G2Point([uint256(0x2cebd0fbd21aca01910581537b21ae4fed46bc0e524c055059aa164ba0a6b62b), uint256(0x18fd4a7bc386cf03a95af7163d5359165acc4e7961cb46519e6d9ee4a1e2b7e9)], [uint256(0x11449dee0199ef6d8eebfe43b548e875c69e7ce37705ee9a00c81fe52f11a009), uint256(0x066d0c83b32800d3f335bb9e8ed5e2924cf00e77e6ec28178592eac9898e1a00)]);

Rozwiązaniem, przynajmniej dopóki eksploratory bloków nie dodadzą weryfikacji Zokrates do swoich interfejsów użytkownika, jest udostępnienie programów Zokrates przez deweloperów aplikacji, aby przynajmniej niektórzy użytkownicy mogli je samodzielnie skompilować z odpowiednim kluczem weryfikacyjnym.

Aby to zrobić:

  1. Zainstaluj Zokrates (opens in a new tab).

  2. Utwórz plik dig.zok z programem Zokrates. Poniższy kod zakłada, że zachowałeś oryginalny rozmiar mapy, 10x5.

  3. Skompiluj kod Zokrates i utwórz klucz weryfikacyjny. Klucz weryfikacyjny musi zostać utworzony z tą samą entropią, która została użyta w oryginalnym serwerze, w tym przypadku z pustym ciągiem znaków (opens in a new tab).

    zokrates compile --input dig.zok
    zokrates setup -e ""
    
  4. Utwórz samodzielnie weryfikator w Solidity i zweryfikuj, czy jest on funkcjonalnie identyczny z tym na blockchainie (serwer dodaje komentarz, ale nie jest to ważne).

    zokrates export-verifier
    diff verifier.sol ~/20240901-secret-state/packages/contracts/src/verifier.sol
    

Decyzje projektowe

W każdej wystarczająco złożonej aplikacji istnieją konkurujące ze sobą cele projektowe, które wymagają kompromisów. Przyjrzyjmy się niektórym z nich i dowiedzmy się, dlaczego obecne rozwiązanie jest lepsze od innych opcji.

Dlaczego wiedza zerowa

W przypadku sapera tak naprawdę nie potrzebujesz wiedzy zerowej. Serwer może zawsze przechowywać mapę, a następnie po prostu ujawnić ją w całości po zakończeniu gry. Następnie, na koniec gry, inteligentny kontrakt może obliczyć hash mapy, zweryfikować, czy się zgadza, a jeśli nie, ukarać serwer lub całkowicie zignorować grę.

Nie użyłem tego prostszego rozwiązania, ponieważ sprawdza się ono tylko w przypadku krótkich gier z dobrze zdefiniowanym stanem końcowym. Kiedy gra jest potencjalnie nieskończona (jak ma to miejsce w przypadku autonomicznych światów (opens in a new tab)), potrzebujesz rozwiązania, które udowadnia stan bez jego ujawniania.

Jako samouczek, ten artykuł wymagał krótkiej gry, która jest łatwa do zrozumienia, ale ta technika jest najbardziej przydatna w przypadku dłuższych gier.

Dlaczego Zokrates?

Zokrates (opens in a new tab) nie jest jedyną dostępną biblioteką z wiedzą zerową, ale jest podobny do normalnego, imperatywnego (opens in a new tab) języka programowania i obsługuje zmienne logiczne.

W przypadku Twojej aplikacji, o innych wymaganiach, możesz woleć użyć Circum (opens in a new tab) lub Cairo (opens in a new tab).

Kiedy kompilować Zokratesa

W tym programie kompilujemy programy Zokratesa za każdym razem, gdy serwer się uruchamia (opens in a new tab). Jest to wyraźne marnowanie zasobów, ale to jest samouczek, zoptymalizowany pod kątem prostoty.

Gdybym pisał aplikację na poziomie produkcyjnym, sprawdziłbym, czy mam plik ze skompilowanymi programami Zokratesa dla tego rozmiaru pola minowego, a jeśli tak, użyłbym go. To samo dotyczy wdrożenia kontraktu weryfikatora onchain.

Tworzenie kluczy weryfikatora i provera

Tworzenie kluczy (opens in a new tab) to kolejna czysta kalkulacja, której nie trzeba wykonywać więcej niż raz dla danego rozmiaru pola minowego. Ponownie, ze względu na prostotę, jest to robione tylko raz.

Dodatkowo moglibyśmy użyć ceremonii konfiguracji (setup ceremony) (opens in a new tab). Zaletą ceremonii konfiguracji jest to, że aby oszukać dowód z wiedzą zerową, potrzebujesz entropii lub pewnego wyniku pośredniego od każdego uczestnika. Jeśli przynajmniej jeden uczestnik ceremonii jest uczciwy i usunie te informacje, dowody z wiedzą zerową są bezpieczne przed pewnymi atakami. Nie ma jednak żadnego mechanizmu, aby zweryfikować, czy informacje zostały usunięte z każdego miejsca. Jeśli dowody z wiedzą zerową są krytycznie ważne, warto wziąć udział w ceremonii konfiguracji.

Tutaj polegamy na perpetual powers of tau (opens in a new tab), w którym brały udział dziesiątki uczestników. Jest to prawdopodobnie wystarczająco bezpieczne i znacznie prostsze. Nie dodajemy również entropii podczas tworzenia klucza, co ułatwia użytkownikom weryfikację konfiguracji z wiedzą zerową.

Gdzie weryfikować

Możemy weryfikować dowody z wiedzą zerową onchain (co kosztuje gaz) lub w kliencie (używając verify (opens in a new tab)). Wybrałem to pierwsze, ponieważ pozwala to zweryfikować weryfikatora raz, a następnie ufać, że się nie zmieni, dopóki adres kontraktu dla niego pozostanie taki sam. Gdyby weryfikacja była przeprowadzana w kliencie, musiałbyś weryfikować otrzymywany kod za każdym razem, gdy pobierasz klienta.

Ponadto, chociaż ta gra jest dla jednego gracza, wiele gier blockchain jest wieloosobowych. Weryfikacja onchain oznacza, że weryfikujesz dowód z wiedzą zerową tylko raz. Robienie tego w kliencie wymagałoby od każdego klienta niezależnej weryfikacji.

Spłaszczyć mapę w TypeScript czy Zokratesie?

Ogólnie rzecz biorąc, gdy przetwarzanie może być wykonane w TypeScript lub Zokratesie, lepiej jest to zrobić w TypeScript, który jest znacznie szybszy i nie wymaga dowodów z wiedzą zerową. Z tego powodu na przykład nie dostarczamy Zokratesowi hasha i nie każemy mu weryfikować, czy jest poprawny. Haszowanie musi być wykonane wewnątrz Zokratesa, ale dopasowanie między zwróconym hashem a hashem onchain może nastąpić poza nim.

Jednak nadal spłaszczamy mapę w Zokratesie (opens in a new tab), podczas gdy moglibyśmy to zrobić w TypeScript. Powodem jest to, że inne opcje są, moim zdaniem, gorsze.

  • Dostarczenie jednowymiarowej tablicy wartości logicznych do kodu Zokratesa i użycie wyrażenia takiego jak x*(height+2)+y, aby uzyskać dwuwymiarową mapę. To sprawiłoby, że kod (opens in a new tab) byłby nieco bardziej skomplikowany, więc uznałem, że zysk wydajności nie jest tego wart w przypadku samouczka.

  • Wysłanie Zokratesowi zarówno jednowymiarowej, jak i dwuwymiarowej tablicy. Jednak to rozwiązanie nic nam nie daje. Kod Zokratesa musiałby zweryfikować, czy dostarczona mu jednowymiarowa tablica jest rzeczywiście poprawną reprezentacją dwuwymiarowej tablicy. Nie byłoby więc żadnego zysku wydajności.

  • Spłaszczenie dwuwymiarowej tablicy w Zokratesie. Jest to najprostsza opcja, więc ją wybrałem.

Gdzie przechowywać mapy

W tej aplikacji gamesInProgress (opens in a new tab) jest po prostu zmienną w pamięci. Oznacza to, że jeśli serwer ulegnie awarii i będzie musiał zostać zrestartowany, wszystkie przechowywane przez niego informacje zostaną utracone. Gracze nie tylko nie będą mogli kontynuować swojej gry, ale nawet nie będą mogli rozpocząć nowej, ponieważ komponent onchain uważa, że ich gra wciąż trwa.

Jest to wyraźnie zły projekt dla systemu produkcyjnego, w którym przechowywałbyś te informacje w bazie danych. Jedynym powodem, dla którego użyłem tutaj zmiennej, jest to, że jest to samouczek, a prostota jest głównym czynnikiem.

Wniosek: W jakich warunkach jest to odpowiednia technika?

Więc teraz wiesz, jak napisać grę z serwerem, który przechowuje tajny stan, który nie powinien znajdować się onchain. Ale w jakich przypadkach należy to zrobić? Należy wziąć pod uwagę dwie główne kwestie.

  • Długotrwała gra: Jak wspomniano powyżej, w krótkiej grze możesz po prostu opublikować stan po jej zakończeniu i wtedy wszystko zweryfikować. Ale nie jest to opcja, gdy gra trwa długo lub w nieskończoność, a stan musi pozostać tajny.

  • Pewna centralizacja jest akceptowalna: Dowody z wiedzą zerową mogą zweryfikować integralność, czyli to, że dany podmiot nie fałszuje wyników. Nie mogą jednak zagwarantować, że podmiot ten nadal będzie dostępny i będzie odpowiadał na wiadomości. W sytuacjach, w których dostępność również musi być zdecentralizowana, dowody z wiedzą zerową nie są wystarczającym rozwiązaniem i potrzebne są obliczenia wielostronne (opens in a new tab).

Zobacz tutaj więcej moich prac (opens in a new tab).

Podziękowania

  • Alvaro Alonso przeczytał wersję roboczą tego artykułu i wyjaśnił niektóre z moich nieporozumień dotyczących Zokratesa.

Za wszelkie pozostałe błędy ponoszę odpowiedzialność.