Przejdź do głównej zawartości

Użycie technologii zerowej wiedzy dla tajnego stanu

serwer
offchain
scentralizowane
zerowej-wiedzy
zokrates
mud
Zaawansowane funkcje
Ori Pomerantz
15 marca 2025
24 minuta czytania

W 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 saperaopens in a new tab nie ma absolutnie żadnego sensu, jeśli można po prostu wejść do eksploratora bloków i zobaczyć mapę.

Najprostszym rozwiązaniem jest użycie komponentu serwera do przechowywania tajnego stanu. Jednak powodem, dla którego używamy blockchain, jest zapobieganie oszustwom ze strony dewelopera gry. Musimy zapewnić uczciwość komponentu serwera. Serwer może dostarczyć hasz stanu i użyć dowodów zerowej wiedzy, aby udowodnić, że stan użyty do obliczenia wyniku ruchu jest prawidłowy.

Po przeczytaniu tego artykułu dowiesz się, jak stworzyć tego rodzaju serwer przechowujący tajny stan, klienta do pokazywania stanu oraz komponent onchain do komunikacji między nimi. Głównymi narzędziami, których użyjemy, będą:

NarzędzieCelZweryfikowano w wersji
Zokratesopens in a new tabDowody zerowej wiedzy i ich weryfikacja1.1.9
Typescriptopens in a new tabJęzyk programowania zarówno dla serwera, jak i klienta5.4.2
Nodeopens in a new tabUruchamianie serwera20.18.2
Viemopens in a new tabKomunikacja z blockchainem2.9.20
MUDopens in a new tabZarządzanie danymi onchain2.0.12
Reactopens in a new tabInterfejs użytkownika klienta18.2.0
Viteopens in a new tabSerwowanie kodu klienta4.2.1

Przykład sapera

Saperopens in a new tab to gra, która zawiera tajną mapę z polem minowym. Gracz wybiera kopanie w określonym miejscu. Jeśli w tym miejscu jest mina, gra się kończy. W przeciwnym razie gracz otrzymuje liczbę min na ośmiu polach otaczających to miejsce.

Ta aplikacja jest napisana przy użyciu MUDopens 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 offchain. Oprócz synchronizacji MUD ułatwia zapewnienie kontroli dostępu, a innym użytkownikom pozwala na rozszerzanieopens in a new tab naszej aplikacji bez potrzeby uzyskiwania zgody.

Uruchamianie przykładu sapera

Aby uruchomić przykład sapera:

  1. Upewnij się, że masz zainstalowane wymagania wstępneopens in a new tab: Nodeopens in a new tab, Foundryopens in a new tab, gitopens in a new tab, pnpmopens in a new tab i mprocsopens in a new tab.

  2. Sklonuj repozytorium.

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

    1cd 20240901-secret-state/
    2pnpm install
    3npm install -g mprocs

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

  4. Skompiluj kontrakty

    1cd packages/contracts
    2forge build
    3cd ../..
  5. Uruchom program (w tym blockchain anvilopens in a new tab) i poczekaj.

    1mprocs

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

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

    Ekran mprocs

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

    • Anvil

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

      1cd packages/contracts
      2pnpm mud dev-contracts --rpc http://127.0.0.1:8545
    • Serwer

      1cd packages/server
      2pnpm start
    • Klient

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

Tabele

Potrzebujemy kilku tabelopens in a new tab onchain.

  • Configuration: Ta tabela jest singletonem, nie ma klucza i ma jeden 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 jest również singletonem. Służy do przechowywania jednej części konfiguracji, adresu kontraktu weryfikatora (verifier). Mogliś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 haszem 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 odkryć 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 haszem mapy, na której gra gracz (identyfikator gry).
    • współrzędna x
    • współrzędna y

    Wartością jest pojedyncza liczba. To 255, jeśli wykryto bombę. W przeciwnym razie jest to liczba bomb wokół tego miejsca 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 są zerowe. Musimy rozróżnić między „gracz jeszcze tutaj nie kopał” a „gracz tu kopał i odkrył, że wokół nie ma bomb”.

Dodatkowo 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 offchainopens in a new tab, co oznacza, że nie jest zapisywana w pamięci EVM, jest czytelna tylko offchain za pomocą zdarzeń.

Przepływy wykonania i danych

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

Inicjalizacja

Po uruchomieniu mprocs następują następujące kroki:

  1. mprocsopens in a new tab uruchamia cztery komponenty:

  2. Pakiet contracts wdraża kontrakty MUD, a następnie uruchamia skrypt PostDeploy.s.solopens in a new tab. Ten skrypt ustawia konfigurację. Kod z GitHub określa pole minowe o wymiarach 10x5 z ośmioma minamiopens in a new tab.

  3. Serweropens in a new tab rozpoczyna od skonfigurowania MUDopens in a new tab. Między innymi aktywuje to synchronizację danych, dzięki czemu kopia odpowiednich tabel istnieje w pamięci serwera.

  4. Serwer subskrybuje funkcję, która ma zostać wykonana, gdy tabela Configuration ulegnie zmianieopens in a new tab. Ta funkcjaopens in a new tab jest wywoływana po wykonaniu PostDeploy.s.sol i zmodyfikowaniu tabeli.

  5. Gdy funkcja inicjalizacyjna serwera ma konfigurację, wywołuje zkFunctionsopens in a new tab, aby zainicjować część serwera opartą na technologii zerowej wiedzy. Nie może się to zdarzyć, dopóki nie uzyskamy konfiguracji, ponieważ funkcje zerowej wiedzy muszą mieć szerokość i wysokość pola minowego jako stałe.

  6. Po zainicjowaniu części serwera opartej na technologii zerowej wiedzy następnym krokiem jest wdrożenie kontraktu weryfikacji zerowej wiedzy na blockchainopens in a new tab i ustawienie adresu weryfikowanego w MUD.

  7. Na koniec subskrybujemy aktualizacje, aby zobaczyć, kiedy gracz zażąda rozpoczęcia nowej gryopens in a new tab lub kopania w istniejącej grzeopens in a new tab.

Nowa gra

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

  1. Jeśli dla tego gracza nie ma żadnej gry w toku lub jest jedna, ale z gameId równym zero, klient wyświetla przycisk nowej gryopens in a new tab. Gdy użytkownik naciśnie ten przycisk, React uruchamia funkcję newGameopens in a new tab.

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

  3. Funkcja onchain sprawdza, czy gracz nie ma gry w toku, a jeśli nie, dodaje żądanie do tabeli PendingGameopens in a new tab.

  4. Serwer wykrywa zmianę w PendingGame i uruchamia subskrybowaną funkcjęopens in a new tab. Ta funkcja wywołuje newGameopens in a new tab, która z kolei wywołuje createGameopens in a new tab.

  5. Pierwszą rzeczą, jaką robi createGame, jest stworzenie losowej mapy z odpowiednią liczbą minopens in a new tab. Następnie wywołuje makeMapBordersopens in a new tab, aby utworzyć mapę z pustymi obramowaniami, co jest konieczne dla Zokrates. Na koniec createGame wywołuje calculateMapHash, aby uzyskać hasz mapy, który jest używany jako ID gry.

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

  7. Ostatnią rzeczą, jaką robi serwer, jest wywołanie app__newGameResponseopens in a new tab, które jest onchain. Ta funkcja znajduje się w innym System, ServerSystemopens in a new tab, aby umożliwić kontrolę dostępu. Kontrola dostępu jest zdefiniowana w pliku konfiguracyjnym MUDopens in a new tab, mud.config.tsopens in a new tab.

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

  8. Komponent onchain aktualizuje odpowiednie tabele:

    • Utwórz grę w PlayerGame.
    • Ustaw odwrotne mapowanie w GamePlayer.
    • Usuń żądanie z PendingGame.
  9. Serwer identyfikuje zmianę w PendingGame, ale nic nie robi, ponieważ wantsGameopens in a new tab jest fałszywe.

  10. Na kliencie gameRecordopens in a new tab jest ustawiony na wpis PlayerGame dla adresu gracza. Gdy PlayerGame się zmienia, gameRecord również się zmienia.

  11. Jeśli w gameRecord jest 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 mapyopens in a new tab, co wywołuje funkcję digopens in a new tab. Ta funkcja wywołuje dig onchainopens in a new tab.

  2. Komponent onchain wykonuje szereg testów poprawnościopens in a new tab, a jeśli się powiedzie, dodaje żądanie kopania do PendingDigopens in a new tab.

  3. Serwer wykrywa zmianę w PendingDigopens in a new tab. Jeśli jest prawidłoweopens in a new tab, wywołuje kod zerowej wiedzyopens in a new tab (wyjaśniony poniżej), aby wygenerować zarówno wynik, jak i dowód jego poprawności.

  4. Serweropens in a new tab wywołuje digResponseopens in a new tab onchain.

  5. digResponse robi dwie rzeczy. Po pierwsze, sprawdza dowód zerowej wiedzyopens in a new tab. Następnie, jeśli dowód jest poprawny, wywołuje processDigResultopens in a new tab, aby faktycznie przetworzyć wynik.

  6. processDigResult sprawdza, czy gra została przegranaopens in a new tab lub wygranaopens in a new tab i aktualizuje Map, mapę onchainopens in a new tab.

  7. Klient automatycznie pobiera aktualizacje i aktualizuje mapę wyświetlaną graczowiopens in a new tab, a w stosownych przypadkach informuje gracza, czy jest to wygrana, czy przegrana.

Korzystanie z Zokrates

W przepływach wyjaśnionych powyżej pominęliśmy części dotyczące zerowej wiedzy, traktując je jak czarną skrzynkę. Teraz otwórzmy ją i zobaczmy, jak ten kod jest napisany.

Haszowanie mapy

Możemy użyć tego kodu JavaScriptopens in a new tab, aby zaimplementować Poseidonopens in a new tab, funkcję haszującą Zokrates, której używamy. Jednakże, chociaż byłoby to szybsze, byłoby to również bardziej skomplikowane niż tylko użycie funkcji haszującej 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 haszu mapy (hash) i drugiego do faktycznego stworzenia dowodu zerowej wiedzy wyniku kopania w danym miejscu na mapie (dig).

Funkcja haszująca

Jest to funkcja, która oblicza hasz mapy. Przejdziemy przez ten kod linia po linii.

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

Te dwie linie importują dwie funkcje z biblioteki standardowej Zokratesopens in a new tab. Pierwsza funkcjaopens in a new tab to hasz Poseidonopens in a new tab. Przyjmuje tablicę elementów fieldopens in a new tab i zwraca field.

Element pola w Zokrates ma zwykle mniej niż 256 bitów, ale niewiele. Aby uprościć kod, ograniczamy mapę do 512 bitów i haszujemy tablicę czterech pól, a w każdym polu używamy tylko 128 bitów. Funkcja pack128opens in a new tab zmienia w tym celu tablicę 128 bitów na field.

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

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

Możemy użyć ${width+2} i ${height+2}, ponieważ programy Zokrates są przechowywane w tej aplikacji jako ciągi szablonówopens in a new tab. Kod między ${ a } jest oceniany przez JavaScript, dzięki czemu program może być używany dla różnych rozmiarów mapy. Parametr mapy ma wokół siebie obramowanie o szerokości jednego miejsca bez żadnych bomb, co jest powodem, dla którego musimy dodać dwa do szerokości i wysokości.

Zwracana wartość to field, który zawiera hasz.

1 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 mutopens in a new tab.

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

1 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.

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

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

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

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

1 field[4] hashMe = [
2 pack128(map1d[0..128]),
3 pack128(map1d[128..256]),
4 pack128(map1d[256..384]),
5 pack128(map1d[384..512])
6 ];

Użyj 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.

1 return poseidon(hashMe);
2}

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

Program haszujący

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

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

Program do kopania

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

1${hashFragment}
2
3// The number of mines in location (x,y)
4def map2mineCount(bool[${width+2}][${height+2}] map, u32 x, u32 y) -> u8 {
5 return if map[x+1][y+1] { 1 } else { 0 };
6}

Dlaczego obramowanie mapy

Dowody zerowej wiedzy używają obwodów arytmetycznychopens in a new tab, które nie mają łatwego odpowiednika instrukcji if. Zamiast tego używają odpowiednika operatora warunkowegoopens in a new tab. Jeśli a może być zerem lub jedynką, możesz obliczyć if a { b } else { c } jako ab+(1-a)c.

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

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

Spowoduje to 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 jednego miejsca wokół całej mapy. Musimy obliczyć całkowitą liczbę min wokół danego miejsca, a to oznacza, że musimy widzieć miejsce o jeden wiersz powyżej i poniżej, na lewo i na prawo od miejsca, w którym kopiemy. Oznacza to, że te miejsca muszą istnieć w tablicy mapy, która jest dostarczana do Zokrates.

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

Domyślnie dowody Zokrates zawierają swoje dane wejściowe. Nie ma sensu wiedzieć, że wokół danego miejsca jest pięć min, jeśli faktycznie nie wiesz, które to miejsce (i nie możesz po prostu dopasować go do swojego żądania, ponieważ wtedy dowodzący mógłby użyć innych wartości i ci o tym nie powiedzieć). Jednak musimy zachować 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 kolejne pole do nadużyć. Dowodzący mógłby użyć poprawnych współrzędnych, ale stworzyć mapę z dowolną liczbą min wokół miejsca, a być może i w samym miejscu. Aby zapobiec temu nadużyciu, sprawiamy, że dowód zerowej wiedzy zawiera hasz mapy, który jest identyfikatorem gry.

1 return (hashMap(map),

Zwracana wartość jest tu krotką, która zawiera zarówno hasz mapy, jak i wynik kopania.

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

Używamy 255 jako specjalnej wartości w przypadku, gdy samo miejsce zawiera bombę.

1 map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
2 map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
3 map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
4 }
5 );
6}

Jeśli gracz nie trafił na minę, dodaj liczby min z obszaru wokół miejsca i zwróć tę wartość.

Używanie Zokrates z TypeScript

Zokrates ma interfejs wiersza poleceń, ale w tym programie używamy go w kodzie TypeScriptopens in a new tab.

Biblioteka zawierająca definicje Zokrates nazywa się zero-knowledge.tsopens in a new tab.

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

Importuj powiązania JavaScript Zokratesopens in a new tab. Potrzebujemy tylko funkcji initializeopens in a new tab, ponieważ zwraca ona obietnicę, która rozwiązuje się do wszystkich definicji Zokrates.

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

Podobnie jak sam Zokrates, my również eksportujemy tylko jedną funkcję, która jest również asynchronicznaopens in a new tab. Kiedy w końcu zwróci wartość, dostarczy kilka funkcji, jak zobaczymy poniżej.

1const zokrates = await zokratesInitialize()

Zainicjuj Zokrates, pobierz wszystko, czego potrzebujemy z biblioteki.

1const hashFragment = `
2 import "utils/pack/bool/pack128.zok" as pack128;
3 import "hashes/poseidon/poseidon.zok" as poseidon;
4 .
5 .
6 .
7 }
8 `
9
10const hashProgram = `
11 ${hashFragment}
12 .
13 .
14 .
15 `
16
17const digProgram = `
18 ${hashFragment}
19 .
20 .
21 .
22 `
Pokaż wszystko

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

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

Tutaj kompilujemy te programy.

1// Create the keys for zero knowledge verification.
2// On a production system you'd want to use a setup ceremony.
3// (https://zokrates.github.io/toolbox/trusted_setup.html#initializing-a-phase-2-ceremony).
4const keySetupResults = zokrates.setup(digCompiled.program, "")
5const verifierKey = keySetupResults.vk
6const proverKey = keySetupResults.pk

W systemie produkcyjnym moglibyśmy użyć bardziej skomplikowanej ceremonii konfiguracjiopens in a new tab, ale to jest wystarczające do demonstracji. Nie jest problemem, że użytkownicy mogą znać klucz dowodzącego - 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 ich powtarzać za każdym razem, tylko gdy zmienia się rozmiar mapy. W systemie produkcyjnym zrobisz to raz, a następnie zapiszesz wynik. Jedynym powodem, dla którego tego tu nie robię, jest prostota.

calculateMapHash

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

Funkcja computeWitnessopens 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 jest informacją potrzebną do stworzenia dowodu zerowej wiedzy wyniku. Tutaj potrzebujemy tylko wyniku.

Wynik to ciąg znaków w postaci "31337", liczba dziesiętna w cudzysłowach. 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 uruchomić pozostały ciąg, który jest liczbą dziesiętną, do BigIntopens in a new tab. .toString(16) konwertuje ten BigInt na ciąg szesnastkowy, a "0x"+ dodaje znacznik dla liczb szesnastkowych.

1// Dig and return a zero knowledge proof of the result
2// (server-side code)

Dowód zerowej wiedzy zawiera publiczne dane wejściowe (x i y) oraz wyniki (hasz mapy i liczba bomb).

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

Sprawdzanie, czy indeks jest poza zakresem, jest problematyczne w Zokrates, więc robimy to tutaj.

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

Wykonaj program do kopania.

1 const proof = zokrates.generateProof(
2 digCompiled.program,
3 runResults.witness,
4 proverKey)
5
6 return proof
7 }

Użyj generateProofopens in a new tab i zwróć dowód.

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

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

1 return {
2 zkDig,
3 calculateMapHash,
4 solidityVerifier,
5 }
6}

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 się ujawni. Ale jeśli aplikacja jest niebezpieczna, prawdopodobnie pozostanie to ukryte przez długi czas, zanim zostanie ujawnione przez kogoś, kto oszukuje i ucieka z zasobami należącymi do innych.

Uprawnienia

W tej grze jest jeden uprzywilejowany podmiot, serwer. Jest to jedyny użytkownik, który może wywoływać funkcje w ServerSystemopens in a new tab. Możemy użyć castopens in a new tab, aby zweryfikować, czy wywołania funkcji z uprawnieniami są dozwolone tylko z konta serwera.

Klucz prywatny serwera znajduje się w setupNetwork.tsopens in a new tab.

  1. Na komputerze, który uruchamia anvil (blockchain), ustaw te zmienne środowiskowe.

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

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

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

  3. Ustaw adres weryfikatora jako adres serwera.

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

    Adres w app__VerifiedAddress powinien być teraz zerowy.

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 nie, możesz sprawdzić inne funkcje w ServerSystemopens in a new tab.

Nadużycia w technologii zerowej wiedzy

Matematyka do weryfikacji Zokrates wykracza poza zakres tego samouczka (i moich umiejętności). Możemy jednak przeprowadzić różne testy na kodzie zerowej wiedzy, aby zweryfikować, że jeśli nie jest on wykonany poprawnie, to zawodzi. Wszystkie te testy będą wymagały zmiany zero-knowledge.tsopens in a new tab i ponownego uruchomienia całej aplikacji. Nie wystarczy ponowne uruchomienie procesu serwera, ponieważ stawia to aplikację w niemożliwym stanie (gracz ma grę w toku, ale gra nie jest już dostępna dla serwera).

Zła odpowiedź

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

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

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

1 cause: {
2 code: 3,
3 message: 'execution reverted: revert: Zero knowledge verification fail',
4 data: '0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000
5000000000000000000000000000000000000000000000000205a65726f206b6e6f776c6564676520766572696669636174696f6
6e206661696c'
7 },

Więc tego rodzaju oszustwo kończy się niepowodzeniem.

Zły dowód

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

1proof.proof = {
2 a: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
3 b: [
4 ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
5 ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
6 ],
7 c: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
8}

Nadal zawodzi, ale teraz zawodzi bez podania przyczyny, ponieważ dzieje się to podczas wywołania weryfikatora.

Jak użytkownik może zweryfikować kod zerowej wiedzy?

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 Systemów MUD jest to nieco bardziej skomplikowaneopens in a new tab, ale niewiele.

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

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

Rozwiązaniem, przynajmniej do czasu, gdy eksploratory bloków dodadzą weryfikację Zokrates do swoich interfejsów użytkownika, jest udostępnienie przez deweloperów aplikacji programów Zokrates, a przynajmniej niektórzy użytkownicy będą je kompilować samodzielnie z odpowiednim kluczem weryfikacyjnym.

Aby to zrobić:

  1. Zainstaluj Zokratesopens 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.

    1 import "utils/pack/bool/pack128.zok" as pack128;
    2 import "hashes/poseidon/poseidon.zok" as poseidon;
    3
    4 def hashMap(bool[12][7] map) -> field {
    5 bool[512] mut map1d = [false; 512];
    6 u32 mut counter = 0;
    7
    8 for u32 x in 0..12 {
    9 for u32 y in 0..7 {
    10 map1d[counter] = map[x][y];
    11 counter = counter+1;
    12 }
    13 }
    14
    15 field[4] hashMe = [
    16 pack128(map1d[0..128]),
    17 pack128(map1d[128..256]),
    18 pack128(map1d[256..384]),
    19 pack128(map1d[384..512])
    20 ];
    21
    22 return poseidon(hashMe);
    23 }
    24
    25
    26 // The number of mines in location (x,y)
    27 def map2mineCount(bool[12][7] map, u32 x, u32 y) -> u8 {
    28 return if map[x+1][y+1] { 1 } else { 0 };
    29 }
    30
    31 def main(private bool[12][7] map, u32 x, u32 y) -> (field, u8) {
    32 return (hashMap(map) ,
    33 if map2mineCount(map, x, y) > 0 { 0xFF } else {
    34 map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
    35 map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
    36 map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
    37 }
    38 );
    39 }
    Pokaż wszystko
  3. Skompiluj kod Zokrates i utwórz klucz weryfikacyjny. Klucz weryfikacyjny musi być utworzony z tą samą entropią, która została użyta w oryginalnym serwerze, w tym przypadku pustym ciągiem znakówopens in a new tab.

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

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

Decyzje projektowe

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

Dlaczego zerowa wiedza

Do gry w sapera tak naprawdę nie potrzebujesz zerowej wiedzy. Serwer zawsze może przechowywać mapę, a następnie po prostu ujawnić ją całą, gdy gra się skończy. Następnie, na koniec gry, inteligentny kontrakt może obliczyć hasz 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ż działa ono tylko w przypadku krótkich gier z dobrze zdefiniowanym stanem końcowym. Gdy gra jest potencjalnie nieskończona (jak w przypadku autonomicznych światówopens in a new tab), potrzebujesz rozwiązania, które udowadnia stan bez jego ujawniania.

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

Dlaczego Zokrates?

Zokratesopens in a new tab nie jest jedyną dostępną biblioteką zerowej wiedzy, ale jest podobny do normalnego, imperatywnegoopens in a new tab języka programowania i obsługuje zmienne logiczne.

Dla Twojej aplikacji, z innymi wymaganiami, możesz preferować użycie Circumopens in a new tab lub Cairoopens in a new tab.

Kiedy kompilować Zokrates

W tym programie kompilujemy programy Zokrates przy każdym uruchomieniu serweraopens in a new tab. Jest to oczywiste marnotrawstwo 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 Zokrates dla tego rozmiaru pola minowego, a jeśli tak, użyłbym go. To samo dotyczy wdrażania kontraktu weryfikatora onchain.

Tworzenie kluczy weryfikatora i dowodzącego

Tworzenie kluczyopens in a new tab to kolejne czyste obliczenie, które nie musi być wykonywane więcej niż raz dla danego rozmiaru pola minowego. Ponownie, robi się to tylko raz dla uproszczenia.

Dodatkowo moglibyśmy użyć ceremonii konfiguracjiopens in a new tab. Zaletą ceremonii konfiguracji jest to, że potrzebujesz albo entropii, albo jakiegoś pośredniego wyniku od każdego uczestnika, aby oszukać dowód zerowej wiedzy. Jeśli przynajmniej jeden uczestnik ceremonii jest uczciwy i usunie te informacje, dowody zerowej wiedzy są bezpieczne przed niektórymi atakami. Jednak nie ma mechanizmu, aby zweryfikować, czy informacje zostały usunięte zewsząd. Jeśli dowody zerowej wiedzy są krytycznie ważne, chcesz uczestniczyć w ceremonii konfiguracji.

Tutaj polegamy na perpetual powers of tauopens 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 kluczy, co ułatwia użytkownikom weryfikację konfiguracji zerowej wiedzy.

Gdzie weryfikować

Możemy weryfikować dowody zerowej wiedzy albo onchain (co kosztuje gaz), albo w kliencie (używając verifyopens in a new tab). Wybrałem to pierwsze, ponieważ pozwala to zweryfikować weryfikator raz, a następnie ufać, że się nie zmieni, dopóki adres kontraktu dla niego pozostanie taki sam. Gdyby weryfikacja była przeprowadzana na kliencie, musiałbyś weryfikować kod, który otrzymujesz za każdym razem, gdy pobierasz klienta.

Ponadto, chociaż ta gra jest dla jednego gracza, wiele gier na blockchainie jest wieloosobowych. Weryfikacja onchain oznacza, że weryfikujesz dowód zerowej wiedzy tylko raz. Robienie tego w kliencie wymagałoby, aby każdy klient weryfikował niezależnie.

Spłaszczyć mapę w TypeScript czy Zokrates?

Ogólnie rzecz biorąc, gdy przetwarzanie może być wykonane w TypeScript lub Zokrates, lepiej jest to zrobić w TypeScript, który jest znacznie szybszy i nie wymaga dowodów zerowej wiedzy. To jest powód, dla którego, na przykład, nie dostarczamy Zokratesowi haszu i nie każemy mu weryfikować, czy jest on poprawny. Haszowanie musi być wykonane wewnątrz Zokrates, ale dopasowanie między zwróconym haszem a haszem onchain może nastąpić poza nim.

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

  • Dostarczyć jednowymiarową tablicę wartości logicznych do kodu Zokrates i użyć wyrażenia takiego jak x*(height+2) +y, aby uzyskać mapę dwuwymiarową. To sprawiłoby, że kodopens in a new tab byłby nieco bardziej skomplikowany, więc zdecydowałem, że wzrost wydajności nie jest tego wart w przypadku samouczka.

  • Wysłać do Zokrates zarówno tablicę jednowymiarową, jak i dwuwymiarową. Jednak to rozwiązanie nic nam nie daje. Kod Zokrates musiałby zweryfikować, czy dostarczona mu tablica jednowymiarowa jest rzeczywiście poprawną reprezentacją tablicy dwuwymiarowej. Więc nie byłoby żadnego wzrostu wydajności.

  • Spłaszczyć tablicę dwuwymiarową w Zokrates. To jest najprostsza opcja, więc ją wybrałem.

Gdzie przechowywać mapy

W tej aplikacji gamesInProgressopens in a new tab to po prostu zmienna w pamięci. Oznacza to, że jeśli serwer ulegnie awarii i będzie musiał zostać ponownie uruchomiony, wszystkie przechowywane informacje zostaną utracone. Gracze nie tylko nie mogą kontynuować gry, ale nawet nie mogą rozpocząć nowej, ponieważ komponent onchain uważa, że nadal mają grę w toku.

Jest to oczywiście 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.

Wnioski: W jakich warunkach jest to odpowiednia technika?

Więc teraz wiesz, jak napisać grę z serwerem, który przechowuje tajny stan, który nie należy do onchain. Ale w jakich przypadkach powinieneś to robić? Istnieją dwa główne czynniki.

  • Długo trwająca gra: Jak wspomniano powyżej, w krótkiej grze możesz po prostu opublikować stan, gdy gra się skończy i wszystko zostanie wtedy zweryfikowane. Ale to nie jest opcja, gdy gra trwa długo lub w nieskończoność, a stan musi pozostać tajny.

  • Akceptowalna pewna centralizacja: Dowody zerowej wiedzy mogą weryfikować integralność, czyli to, że podmiot nie fałszuje wyników. Nie mogą jednak zapewnić, że podmiot będzie nadal dostępny i będzie odpowiadał na wiadomości. W sytuacjach, w których dostępność również musi być zdecentralizowana, dowody zerowej wiedzy nie są wystarczającym rozwiązaniem i potrzebne jest obliczanie wielostronneopens in a new tab.

Zobacz więcej mojej pracy tutajopens in a new tab.

Podziękowania

  • Alvaro Alonso przeczytał szkic tego artykułu i wyjaśnił niektóre z moich nieporozumień dotyczących Zokrates.

Wszelkie pozostałe błędy są moją odpowiedzialnością.

Strona ostatnio zaktualizowana: 14 lutego 2026

Czy ten samouczek był pomocny?