Weiter zum Hauptinhalt

Verwendung von Zero-Knowledge für einen geheimen Zustand

server
offchain
centralized
zero-knowledge
zokrates
mud
Fortgeschritten
Ori Pomerantz
15. März 2025
27 Minuten Lesedauer

Es gibt keine Geheimnisse in der Blockchain. Alles, was auf der Blockchain gepostet wird, ist für jeden lesbar. Dies ist notwendig, da die Blockchain darauf basiert, dass jeder sie verifizieren kann. Spiele sind jedoch oft auf einen geheimen Zustand angewiesen. Zum Beispiel ergibt das Spiel Minesweeperopens in a new tab absolut keinen Sinn, wenn man einfach in einen Blockchain-Explorer gehen und die Karte sehen kann.

Die einfachste Lösung ist die Verwendung einer Serverkomponente, um den geheimen Zustand zu speichern. Der Grund, warum wir die Blockchain verwenden, ist jedoch, Betrug durch den Spieleentwickler zu verhindern. Wir müssen die Ehrlichkeit der Serverkomponente sicherstellen. Der Server kann einen Hash des Zustands bereitstellen und Zero-Knowledge-Beweise verwenden, um zu beweisen, dass der zur Berechnung des Ergebnisses eines Zuges verwendete Zustand der richtige ist.

Nach der Lektüre dieses Artikels wissen Sie, wie Sie diese Art von Server, der einen geheimen Zustand hält, einen Client zur Anzeige des Zustands und eine Onchain-Komponente für die Kommunikation zwischen den beiden erstellen. Die Hauptwerkzeuge, die wir verwenden werden, sind:

WerkzeugZweckAuf Version verifiziert
Zokratesopens in a new tabZero-Knowledge-Beweise und ihre Verifizierung1.1.9
Typescriptopens in a new tabProgrammiersprache für Server und Client5.4.2
Nodeopens in a new tabAusführen des Servers20.18.2
Viemopens in a new tabKommunikation mit der Blockchain2.9.20
MUDopens in a new tabOnchain-Datenverwaltung2.0.12
Reactopens in a new tabClient-Benutzeroberfläche18.2.0
Viteopens in a new tabBereitstellen des Client-Codes4.2.1

Minesweeper-Beispiel

Minesweeperopens in a new tab ist ein Spiel, das eine geheime Karte mit einem Minenfeld enthält. Der Spieler entscheidet sich, an einer bestimmten Stelle zu graben. Wenn sich an dieser Stelle eine Mine befindet, ist das Spiel vorbei. Andernfalls erhält der Spieler die Anzahl der Minen in den acht Feldern, die diesen Ort umgeben.

Diese Anwendung wurde mit MUDopens in a new tab geschrieben, einem Framework, mit dem wir Daten onchain in einer Schlüssel-Wert-Datenbankopens in a new tab speichern und diese Daten automatisch mit Offchain-Komponenten synchronisieren können. Zusätzlich zur Synchronisation erleichtert MUD die Bereitstellung von Zugriffskontrollen und ermöglicht es anderen Benutzern, unsere Anwendung zu erweiternopens in a new tab, ohne dass eine Berechtigung erforderlich ist.

Ausführen des Minesweeper-Beispiels

So führen Sie das Minesweeper-Beispiel aus:

  1. Stellen Sie sicher, dass Sie die Voraussetzungen installiert habenopens in a new tab: Nodeopens in a new tab, Foundryopens in a new tab, gitopens in a new tab, pnpmopens in a new tab und mprocsopens in a new tab.

  2. Klonen Sie das Repository.

    1git clone https://github.com/qbzzt/20240901-secret-state.git
  3. Installieren Sie die Pakete.

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

    Wenn Foundry als Teil von pnpm install installiert wurde, müssen Sie die Kommandozeilen-Shell neu starten.

  4. Kompilieren Sie die Verträge

    1cd packages/contracts
    2forge build
    3cd ../..
  5. Starten Sie das Programm (einschließlich einer Anvilopens in a new tab Blockchain) und warten Sie.

    1mprocs

    Beachten Sie, dass der Start lange dauert. Um den Fortschritt zu sehen, verwenden Sie zuerst die Pfeiltaste nach unten, um zum Tab contracts zu scrollen, um zu sehen, wie die MUD-Verträge bereitgestellt werden. Wenn Sie die Meldung Waiting for file changes… erhalten, sind die Verträge bereitgestellt und der weitere Fortschritt findet auf der Registerkarte server statt. Dort warten Sie, bis Sie die Nachricht Verifier address: 0x... erhalten.

    Wenn dieser Schritt erfolgreich ist, sehen Sie den mprocs-Bildschirm mit den verschiedenen Prozessen auf der linken und der Konsolenausgabe für den aktuell ausgewählten Prozess auf der rechten Seite.

    Der mprocs-Bildschirm

    Wenn es ein Problem mit mprocs gibt, können Sie die vier Prozesse manuell ausführen, jeder in seinem eigenen Kommandozeilenfenster:

    • Anvil

      1cd packages/contracts
      2anvil --base-fee 0 --block-time 2
    • Verträge

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

      1cd packages/server
      2pnpm start
    • Client

      1cd packages/client
      2pnpm run dev
  6. Jetzt können Sie zum Clientopens in a new tab navigieren, auf New Game klicken und mit dem Spielen beginnen.

Tabellen

Wir benötigen mehrere Tabellenopens in a new tab onchain.

  • Configuration: Diese Tabelle ist ein Singleton, sie hat keinen Schlüssel und einen einzigen Datensatz. Sie wird verwendet, um Informationen zur Spielkonfiguration zu speichern:

    • height: Die Höhe eines Minenfeldes
    • width: Die Breite eines Minenfeldes
    • numberOfBombs: Die Anzahl der Bomben in jedem Minenfeld
  • VerifierAddress: Diese Tabelle ist ebenfalls ein Singleton. Es wird verwendet, um einen Teil der Konfiguration zu halten, die Adresse des Verifizierervertrags (verifier). Wir hätten diese Information in die Configuration-Tabelle aufnehmen können, aber sie wird von einer anderen Komponente, dem Server, gesetzt, daher ist es einfacher, sie in eine separate Tabelle zu packen.

  • PlayerGame: Der Schlüssel ist die Adresse des Spielers. Die Daten sind:

    • gameId: 32-Byte-Wert, der der Hash der Karte ist, auf der der Spieler spielt (der Spiel-Identifikator).
    • win: ein boolescher Wert, der angibt, ob der Spieler das Spiel gewonnen hat.
    • lose: ein boolescher Wert, der angibt, ob der Spieler das Spiel verloren hat.
    • digNumber: die Anzahl der erfolgreichen Grabungen im Spiel.
  • GamePlayer: Diese Tabelle enthält die umgekehrte Zuordnung von gameId zur Spieleradresse.

  • Map: Der Schlüssel ist ein Tupel aus drei Werten:

    • gameId: 32-Byte-Wert, der der Hash der Karte ist, auf der der Spieler spielt (der Spiel-Identifikator).
    • x-Koordinate
    • y-Koordinate

    Der Wert ist eine einzelne Zahl. Es ist 255, wenn eine Bombe entdeckt wurde. Andernfalls ist es die Anzahl der Bomben um diesen Ort herum plus eins. Wir können nicht einfach die Anzahl der Bomben verwenden, da standardmäßig der gesamte Speicher in der EVM und alle Zeilenwerte in MUD null sind. Wir müssen zwischen „der Spieler hat hier noch nicht gegraben“ und „der Spieler hat hier gegraben und festgestellt, dass es keine Bomben in der Nähe gibt“ unterscheiden.

Zusätzlich findet die Kommunikation zwischen Client und Server über die Onchain-Komponente statt. Dies wird ebenfalls mithilfe von Tabellen implementiert.

  • PendingGame: Nicht bearbeitete Anfragen zum Starten eines neuen Spiels.
  • PendingDig: Nicht bearbeitete Anfragen, an einem bestimmten Ort in einem bestimmten Spiel zu graben. Dies ist eine Offchain-Tabelleopens in a new tab, was bedeutet, dass sie nicht in den EVM-Speicher geschrieben wird, sondern nur offchain über Ereignisse lesbar ist.

Ausführungs- und Datenflüsse

Diese Flüsse koordinieren die Ausführung zwischen dem Client, der Onchain-Komponente und dem Server.

Initialisierung

Wenn Sie mprocs ausführen, geschehen diese Schritte:

  1. mprocsopens in a new tab führt vier Komponenten aus:

  2. Das contracts-Paket stellt die MUD-Verträge bereit und führt dann das PostDeploy.s.sol-Skriptopens in a new tab aus. Dieses Skript legt die Konfiguration fest. Der Code von GitHub spezifiziert ein 10x5 Minenfeld mit acht Minen darinopens in a new tab.

  3. Der Serveropens in a new tab beginnt mit der Einrichtung von MUDopens in a new tab. Dies aktiviert unter anderem die Datensynchronisation, sodass eine Kopie der relevanten Tabellen im Speicher des Servers vorhanden ist.

  4. Der Server abonniert eine Funktion, die ausgeführt wird, wenn sich die Configuration-Tabelle ändertopens in a new tab. Diese Funktionopens in a new tab wird aufgerufen, nachdem PostDeploy.s.sol ausgeführt und die Tabelle geändert wurde.

  5. Wenn die Server-Initialisierungsfunktion die Konfiguration hat, ruft sie zkFunctions aufopens in a new tab, um den Zero-Knowledge-Teil des Servers zu initialisieren. Dies kann erst geschehen, wenn wir die Konfiguration erhalten, da die Zero-Knowledge-Funktionen die Breite und Höhe des Minenfeldes als Konstanten haben müssen.

  6. Nachdem der Zero-Knowledge-Teil des Servers initialisiert ist, ist der nächste Schritt, den Zero-Knowledge-Verifizierungsvertrag auf der Blockchain bereitzustellenopens in a new tab und die Verifizierungsadresse in MUD festzulegen.

  7. Schließlich abonnieren wir Aktualisierungen, damit wir sehen, wenn ein Spieler entweder ein neues Spiel startenopens in a new tab oder in einem bestehenden Spiel grabenopens in a new tab möchte.

Neues Spiel

Dies geschieht, wenn der Spieler ein neues Spiel anfordert.

  1. Wenn für diesen Spieler kein Spiel im Gange ist, oder es eines gibt, aber mit einer gameId von Null, zeigt der Client eine Schaltfläche für ein neues Spielopens in a new tab an. Wenn der Benutzer diese Schaltfläche drückt, führt React die Funktion newGame ausopens in a new tab.

  2. newGameopens in a new tab ist ein System-Aufruf. In MUD werden alle Aufrufe über den World-Vertrag geleitet, und in den meisten Fällen rufen Sie <namespace>__<function name> auf. In diesem Fall ist der Aufruf an app__newGame, den MUD dann an newGame in GameSystemopens in a new tab weiterleitet.

  3. Die Onchain-Funktion prüft, ob der Spieler kein Spiel im Gange hat, und wenn nicht, fügt sie die Anfrage zur PendingGame-Tabelle hinzuopens in a new tab.

  4. Der Server erkennt die Änderung in PendingGame und führt die abonnierte Funktion ausopens in a new tab. Diese Funktion ruft newGameopens in a new tab auf, das wiederum createGameopens in a new tab aufruft.

  5. Das Erste, was createGame tut, ist eine zufällige Karte mit der entsprechenden Anzahl von Minen zu erstellenopens in a new tab. Dann ruft es makeMapBordersopens in a new tab auf, um eine Karte mit leeren Rändern zu erstellen, was für Zokrates notwendig ist. Schließlich ruft createGame calculateMapHash auf, um den Hash der Karte zu erhalten, der als Spiel-ID verwendet wird.

  6. Die Funktion newGame fügt das neue Spiel zu gamesInProgress hinzu.

  7. Das Letzte, was der Server tut, ist app__newGameResponseopens in a new tab aufzurufen, was onchain geschieht. Diese Funktion befindet sich in einem anderen System, ServerSystemopens in a new tab, um die Zugriffskontrolle zu ermöglichen. Die Zugriffskontrolle ist in der MUD-Konfigurationsdateiopens in a new tab, mud.config.tsopens in a new tab definiert.

    Die Zugriffsliste erlaubt nur einer einzigen Adresse, das System aufzurufen. Dies beschränkt den Zugriff auf die Serverfunktionen auf eine einzige Adresse, sodass niemand den Server imitieren kann.

  8. Die Onchain-Komponente aktualisiert die relevanten Tabellen:

    • Erstellen Sie das Spiel in PlayerGame.
    • Setzen Sie die umgekehrte Zuordnung in GamePlayer.
    • Entfernen Sie die Anfrage aus PendingGame.
  9. Der Server identifiziert die Änderung in PendingGame, unternimmt aber nichts, da wantsGameopens in a new tab falsch ist.

  10. Auf dem Client wird gameRecordopens in a new tab auf den PlayerGame-Eintrag für die Adresse des Spielers gesetzt. Wenn sich PlayerGame ändert, ändert sich auch gameRecord.

  11. Wenn ein Wert in gameRecord vorhanden ist und das Spiel weder gewonnen noch verloren wurde, zeigt der Client die Karte anopens in a new tab.

Graben

  1. Der Spieler klickt auf die Schaltfläche der Kartenzelleopens in a new tab, wodurch die Funktion dig aufgerufen wirdopens in a new tab. Diese Funktion ruft dig onchain aufopens in a new tab.

  2. Die Onchain-Komponente führt eine Reihe von Plausibilitätsprüfungen durchopens in a new tab und fügt bei Erfolg die Grabanforderung zu PendingDigopens in a new tab hinzu.

  3. Der Server erkennt die Änderung in PendingDigopens in a new tab. Wenn sie gültig istopens in a new tab, ruft sie den Zero-Knowledge-Codeopens in a new tab auf (unten erklärt), um sowohl das Ergebnis als auch einen Beweis für dessen Gültigkeit zu generieren.

  4. Der Serveropens in a new tab ruft digResponseopens in a new tab onchain auf.

  5. digResponse tut zwei Dinge. Zuerst prüft es den Zero-Knowledge-Beweisopens in a new tab. Wenn der Beweis dann standhält, ruft es processDigResultopens in a new tab auf, um das Ergebnis tatsächlich zu verarbeiten.

  6. processDigResult prüft, ob das Spiel verlorenopens in a new tab oder gewonnenopens in a new tab wurde, und aktualisiert Map, die Onchain-Karteopens in a new tab.

  7. Der Client übernimmt die Aktualisierungen automatisch und aktualisiert die dem Spieler angezeigte Karteopens in a new tab und teilt dem Spieler gegebenenfalls mit, ob es ein Sieg oder eine Niederlage ist.

Verwendung von Zokrates

In den oben erklärten Abläufen haben wir die Zero-Knowledge-Teile übersprungen und sie als Blackbox behandelt. Jetzt wollen wir sie aufbrechen und sehen, wie dieser Code geschrieben ist.

Hashing der Karte

Wir können diesen JavaScript-Codeopens in a new tab verwenden, um Poseidonopens in a new tab, die von uns verwendete Zokrates-Hash-Funktion, zu implementieren. Obwohl dies schneller wäre, wäre es auch komplizierter, als einfach die Zokrates-Hash-Funktion dafür zu verwenden. Dies ist ein Tutorial, daher ist der Code auf Einfachheit und nicht auf Leistung optimiert. Daher benötigen wir zwei verschiedene Zokrates-Programme: eines, das nur den Hash einer Karte (hash) berechnet, und eines, das tatsächlich einen Zero-Knowledge-Beweis für das Ergebnis des Grabens an einem Ort auf der Karte (dig) erstellt.

Die Hash-Funktion

Dies ist die Funktion, die den Hash einer Karte berechnet. Wir werden diesen Code Zeile für Zeile durchgehen.

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

Diese beiden Zeilen importieren zwei Funktionen aus der Zokrates-Standardbibliothekopens in a new tab. Die erste Funktionopens in a new tab ist ein Poseidon-Hashopens in a new tab. Es nimmt ein Array von field-Elementenopens in a new tab und gibt ein field zurück.

Das Feldelement in Zokrates ist typischerweise kürzer als 256 Bit, aber nicht viel. Um den Code zu vereinfachen, beschränken wir die Karte auf bis zu 512 Bit und hashen ein Array von vier Feldern, und in jedem Feld verwenden wir nur 128 Bit. Die Funktion pack128opens in a new tab wandelt zu diesem Zweck ein Array von 128 Bits in ein field um.

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

Diese Zeile beginnt eine Funktionsdefinition. hashMap erhält einen einzigen Parameter namens map, ein zweidimensionales bool(esches) Array. Die Größe der Karte ist width+2 mal height+2 aus Gründen, die unten erklärt werden.

Wir können ${width+2} und ${height+2} verwenden, da die Zokrates-Programme in dieser Anwendung als Template-Stringsopens in a new tab gespeichert sind. Code zwischen ${ und } wird von JavaScript ausgewertet, und auf diese Weise kann das Programm für verschiedene Kartengrößen verwendet werden. Der Kartenparameter hat einen einen Ort breiten Rand ringsum ohne Bomben, weshalb wir zwei zur Breite und Höhe hinzufügen müssen.

Der Rückgabewert ist ein field, das den Hash enthält.

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

Die Karte ist zweidimensional. Die Funktion pack128 funktioniert jedoch nicht mit zweidimensionalen Arrays. Also flachen wir die Karte zuerst in ein 512-Byte-Array ab, indem wir map1d verwenden. Standardmäßig sind Zokrates-Variablen Konstanten, aber wir müssen diesem Array in einer Schleife Werte zuweisen, also definieren wir es als mutopens in a new tab.

Wir müssen das Array initialisieren, da Zokrates kein undefined kennt. Der Ausdruck [false; 512] bedeutet ein Array von 512 false-Wertenopens in a new tab.

1 u32 mut counter = 0;

Wir benötigen auch einen Zähler, um zwischen den Bits, die wir bereits in map1d gefüllt haben, und denen, die wir nicht gefüllt haben, zu unterscheiden.

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

So deklarieren Sie eine for-Schleifeopens in a new tab in Zokrates. Eine Zokrates-for-Schleife muss feste Grenzen haben, denn obwohl sie wie eine Schleife aussieht, „entrollt“ der Compiler sie tatsächlich. Der Ausdruck ${width+2} ist eine Kompilierzeitkonstante, da width vom TypeScript-Code gesetzt wird, bevor er den Compiler aufruft.

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

Für jeden Ort auf der Karte, fügen Sie diesen Wert in das map1d-Array ein und erhöhen Sie den Zähler.

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 ];

Das pack128, um ein Array von vier field-Werten aus map1d zu erstellen. In Zokrates bedeutet array[a..b] den Ausschnitt des Arrays, der bei a beginnt und bei b-1 endet.

1 return poseidon(hashMe);
2}

Verwenden Sie poseidon, um dieses Array in einen Hash umzuwandeln.

Das Hash-Programm

Der Server muss hashMap direkt aufrufen, um Spiel-Identifikatoren zu erstellen. Zokrates kann jedoch nur die main-Funktion eines Programms zum Starten aufrufen, daher erstellen wir ein Programm mit einer main-Funktion, die die Hash-Funktion aufruft.

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

Das Grabungsprogramm

Dies ist das Herzstück des Zero-Knowledge-Teils der Anwendung, wo wir die Beweise produzieren, die zur Verifizierung von Grabungsergebnissen verwendet werden.

1${hashFragment}
2
3// Die Anzahl der Minen am Ort (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}

Warum ein Kartenrand

Zero-Knowledge-Beweise verwenden arithmetische Schaltungenopens in a new tab, die keine einfache Entsprechung zu einer if-Anweisung haben. Stattdessen verwenden sie das Äquivalent des bedingten Operatorsopens in a new tab. Wenn a entweder null oder eins sein kann, können Sie if a { b } else { c } als ab+(1-a)c berechnen.

Deshalb wertet eine Zokrates-if-Anweisung immer beide Zweige aus. Wenn Sie zum Beispiel diesen Code haben:

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

Es wird einen Fehler geben, weil es arr[10] berechnen muss, obwohl dieser Wert später mit Null multipliziert wird.

Dies ist der Grund, warum wir einen einen Ort breiten Rand rund um die Karte benötigen. Wir müssen die Gesamtzahl der Minen um einen Ort herum berechnen, und das bedeutet, wir müssen den Ort eine Reihe darüber und darunter, links und rechts von dem Ort, an dem wir graben, sehen. Das bedeutet, dass diese Orte in dem Karten-Array existieren müssen, das Zokrates zur Verfügung gestellt wird.

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

Standardmäßig enthalten Zokrates-Beweise ihre Eingaben. Es nützt nichts zu wissen, dass sich fünf Minen um einen Punkt befinden, es sei denn, man weiß tatsächlich, um welchen Punkt es sich handelt (und man kann ihn nicht einfach mit seiner Anfrage abgleichen, denn dann könnte der Prüfer andere Werte verwenden und es Ihnen nicht mitteilen). Wir müssen die Karte jedoch geheim halten, während wir sie Zokrates zur Verfügung stellen. Die Lösung ist die Verwendung eines private-Parameters, der nicht durch den Beweis offengelegt wird.

Dies eröffnet eine weitere Möglichkeit für Missbrauch. Der Prüfer könnte die richtigen Koordinaten verwenden, aber eine Karte mit einer beliebigen Anzahl von Minen um den Ort herum und möglicherweise am Ort selbst erstellen. Um diesen Missbrauch zu verhindern, lassen wir den Zero-Knowledge-Beweis den Hash der Karte enthalten, der die Spiel-ID ist.

1 return (hashMap(map),

Der Rückgabewert ist hier ein Tupel, das das Karten-Hash-Array sowie das Grabungsergebnis enthält.

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

Wir verwenden 255 als Sonderwert für den Fall, dass der Ort selbst eine Bombe enthält.

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}

Wenn der Spieler keine Mine getroffen hat, addieren Sie die Minenzählungen für den Bereich um den Ort herum und geben Sie das zurück.

Verwendung von Zokrates aus TypeScript

Zokrates hat eine Befehlszeilenschnittstelle, aber in diesem Programm verwenden wir sie im TypeScript-Codeopens in a new tab.

Die Bibliothek, die die Zokrates-Definitionen enthält, heißt zero-knowledge.tsopens in a new tab.

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

Importieren Sie die Zokrates JavaScript-Bindungenopens in a new tab. Wir benötigen nur die Funktion initializeopens in a new tab, da sie ein Promise zurückgibt, das in alle Zokrates-Definitionen aufgelöst wird.

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

Ähnlich wie bei Zokrates selbst exportieren wir auch nur eine Funktion, die ebenfalls asynchronopens in a new tab ist. Wenn sie schließlich zurückkehrt, stellt sie mehrere Funktionen zur Verfügung, wie wir unten sehen werden.

1const zokrates = await zokratesInitialize()

Initialisieren Sie Zokrates, holen Sie sich alles, was wir aus der Bibliothek benötigen.

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 `
Alles anzeigen

Als Nächstes haben wir die Hash-Funktion und zwei Zokrates-Programme, die wir oben gesehen haben.

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

Hier kompilieren wir diese Programme.

1// Erstellen Sie die Schlüssel für die Zero-Knowledge-Verifizierung.
2// Auf einem Produktionssystem würden Sie eine Setup-Zeremonie verwenden wollen.
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

Auf einem Produktionssystem könnten wir eine kompliziertere Setup-Zeremonieopens in a new tab verwenden, aber das ist für eine Demonstration ausreichend. Es ist kein Problem, dass die Benutzer den Prover-Schlüssel kennen – sie können ihn trotzdem nicht verwenden, um Dinge zu beweisen, es sei denn, sie sind wahr. Da wir die Entropie (der zweite Parameter, "") angeben, werden die Ergebnisse immer die gleichen sein.

Hinweis: Die Kompilierung von Zokrates-Programmen und die Schlüsselerstellung sind langsame Prozesse. Es ist nicht nötig, sie jedes Mal zu wiederholen, nur wenn sich die Kartengröße ändert. Auf einem Produktionssystem würde man sie einmal durchführen und dann die Ausgabe speichern. Der einzige Grund, warum ich es hier nicht tue, ist der Einfachheit halber.

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}

Die Funktion computeWitnessopens in a new tab führt das Zokrates-Programm tatsächlich aus. Sie gibt eine Struktur mit zwei Feldern zurück: output, die Ausgabe des Programms als JSON-String, und witness, die Informationen, die zur Erstellung eines Zero-Knowledge-Beweises des Ergebnisses benötigt werden. Hier benötigen wir nur die Ausgabe.

Die Ausgabe ist ein String der Form "31337", eine in Anführungszeichen eingeschlossene Dezimalzahl. Aber die Ausgabe, die wir für viem benötigen, ist eine hexadezimale Zahl der Form 0x60A7. Also verwenden wir .slice(1,-1), um die Anführungszeichen zu entfernen, und dann BigInt, um den verbleibenden String, der eine Dezimalzahl ist, in einen BigIntopens in a new tab umzuwandeln. .toString(16) wandelt diesen BigInt in einen hexadezimalen String um, und "0x"+ fügt die Markierung für hexadezimale Zahlen hinzu.

1// Graben und einen Zero-Knowledge-Beweis des Ergebnisses zurückgeben
2// (serverseitiger Code)

Der Zero-Knowledge-Beweis enthält die öffentlichen Eingaben (x und y) und Ergebnisse (Hash der Karte und Anzahl der Bomben).

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("Versuch, außerhalb der Karte zu graben")

Es ist ein Problem, in Zokrates zu prüfen, ob ein Index außerhalb der Grenzen liegt, also tun wir es hier.

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

Führen Sie das Grabungsprogramm aus.

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

Verwenden Sie generateProofopens in a new tab und geben Sie den Beweis zurück.

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

Ein Solidity-Verifizierer, ein Smart Contract, den wir auf der Blockchain bereitstellen und verwenden können, um Beweise zu verifizieren, die von digCompiled.program generiert wurden.

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

Schließlich geben Sie alles zurück, was anderer Code benötigen könnte.

Sicherheitstests

Sicherheitstests sind wichtig, denn ein Funktionsfehler wird sich irgendwann von selbst zeigen. Aber wenn die Anwendung unsicher ist, bleibt das wahrscheinlich lange Zeit verborgen, bevor es von jemandem aufgedeckt wird, der betrügt und mit Ressourcen davonkommt, die anderen gehören.

Berechtigungen

Es gibt eine privilegierte Entität in diesem Spiel, den Server. Es ist der einzige Benutzer, der berechtigt ist, die Funktionen in ServerSystemopens in a new tab aufzurufen. Wir können castopens in a new tab verwenden, um zu überprüfen, dass Aufrufe von berechtigten Funktionen nur mit dem Serverkonto erlaubt sind.

Der private Schlüssel des Servers befindet sich in setupNetwork.tsopens in a new tab.

  1. Setzen Sie auf dem Computer, auf dem anvil (die Blockchain) läuft, diese Umgebungsvariablen.

    1WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b
    2UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
    3AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
  2. Verwenden Sie cast, um zu versuchen, die Verifiziereradresse als eine nicht autorisierte Adresse festzulegen.

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

    Nicht nur meldet cast einen Fehler, sondern Sie können auch MUD Dev Tools im Spiel im Browser öffnen, auf Tables klicken und app__VerifierAddress auswählen. Sehen Sie, dass die Adresse nicht null ist.

  3. Setzen Sie die Verifiziereradresse als Adresse des Servers.

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

    Die Adresse in app__VerifiedAddress sollte jetzt null sein.

Alle MUD-Funktionen im selben System durchlaufen dieselbe Zugriffskontrolle, daher halte ich diesen Test für ausreichend. Wenn nicht, können Sie die anderen Funktionen in ServerSystemopens in a new tab überprüfen.

Zero-Knowledge-Missbrauch

Die Mathematik zur Verifizierung von Zokrates liegt außerhalb des Rahmens dieses Tutorials (und meiner Fähigkeiten). Wir können jedoch verschiedene Prüfungen am Zero-Knowledge-Code durchführen, um zu verifizieren, dass er bei fehlerhafter Ausführung fehlschlägt. All diese Tests erfordern, dass wir zero-knowledge.tsopens in a new tab ändern und die gesamte Anwendung neu starten. Es reicht nicht aus, den Serverprozess neu zu starten, da dies die Anwendung in einen unmöglichen Zustand versetzt (der Spieler hat ein Spiel im Gange, aber das Spiel ist für den Server nicht mehr verfügbar).

Falsche Antwort

Die einfachste Möglichkeit besteht darin, im Zero-Knowledge-Beweis die falsche Antwort zu geben. Dazu gehen wir in zkDig und ändern Zeile 91opens in a new tab:

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

Das bedeutet, wir werden immer behaupten, es gäbe eine Bombe, unabhängig von der richtigen Antwort. Versuchen Sie, mit dieser Version zu spielen, und Sie werden auf der Registerkarte server des pnpm dev-Bildschirms diesen Fehler sehen:

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

Diese Art von Betrug schlägt also fehl.

Falscher Beweis

Was passiert, wenn wir die richtigen Informationen liefern, aber nur die falschen Beweisdaten haben? Ersetzen Sie nun Zeile 91 durch:

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}

Es schlägt immer noch fehl, aber jetzt schlägt es ohne Grund fehl, weil es während des Verifizierungsaufrufs passiert.

Wie kann ein Benutzer den Zero-Trust-Code überprüfen?

Smart Contracts sind relativ einfach zu überprüfen. Typischerweise veröffentlicht der Entwickler den Quellcode in einem Block-Explorer, und der Block-Explorer verifiziert, dass der Quellcode zum Code in der Vertragsbereitstellungstransaktion kompiliert. Im Falle von MUD-Systemen ist dies etwas komplizierteropens in a new tab, aber nicht viel.

Mit Zero-Knowledge ist das schwieriger. Der Verifizierer enthält einige Konstanten und führt einige Berechnungen mit ihnen durch. Dies sagt Ihnen nicht, was bewiesen wird.

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)]);

Die Lösung besteht, zumindest bis Block-Explorer dazu übergehen, Zokrates-Verifizierung zu ihren Benutzeroberflächen hinzuzufügen, darin, dass die Anwendungsentwickler die Zokrates-Programme zur Verfügung stellen und dass zumindest einige Benutzer sie selbst mit dem entsprechenden Verifizierungsschlüssel kompilieren.

Dazu gehen Sie wie folgt vor:

  1. Installieren Sie Zokratesopens in a new tab.

  2. Erstellen Sie eine Datei dig.zok mit dem Zokrates-Programm. Der nachstehende Code geht davon aus, dass Sie die ursprüngliche Kartengröße von 10x5 beibehalten haben.

    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 // Die Anzahl der Minen am Ort (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 }
    Alles anzeigen
  3. Kompilieren Sie den Zokrates-Code und erstellen Sie den Verifizierungsschlüssel. Der Verifizierungsschlüssel muss mit der gleichen Entropie erstellt werden, die im ursprünglichen Server verwendet wurde, in diesem Fall ein leerer Stringopens in a new tab.

    1zokrates compile --input dig.zok
    2zokrates setup -e ""
  4. Erstellen Sie den Solidity-Verifizierer selbst und überprüfen Sie, ob er funktionell mit dem auf der Blockchain identisch ist (der Server fügt einen Kommentar hinzu, aber das ist nicht wichtig).

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

Designentscheidungen

In jeder ausreichend komplexen Anwendung gibt es konkurrierende Designziele, die Kompromisse erfordern. Schauen wir uns einige der Kompromisse an und warum die aktuelle Lösung anderen Optionen vorzuziehen ist.

Warum Zero-Knowledge

Für Minesweeper braucht man nicht wirklich Zero-Knowledge. Der Server kann die Karte immer behalten und sie dann einfach aufdecken, wenn das Spiel vorbei ist. Dann kann der Smart Contract am Ende des Spiels den Karten-Hash berechnen, überprüfen, ob er übereinstimmt, und wenn nicht, den Server bestrafen oder das Spiel vollständig ignorieren.

Ich habe diese einfachere Lösung nicht verwendet, da sie nur für kurze Spiele mit einem genau definierten Endzustand funktioniert. Wenn ein Spiel potenziell unendlich ist (wie im Fall von autonomen Weltenopens in a new tab), benötigen Sie eine Lösung, die den Zustand beweist, ohne ihn preiszugeben.

Als Tutorial benötigte dieser Artikel ein kurzes, leicht verständliches Spiel, aber diese Technik ist am nützlichsten für längere Spiele.

Warum Zokrates?

Zokratesopens in a new tab ist nicht die einzige verfügbare Zero-Knowledge-Bibliothek, aber sie ähnelt einer normalen, imperativenopens in a new tab Programmiersprache und unterstützt boolesche Variablen.

Für Ihre Anwendung, mit unterschiedlichen Anforderungen, bevorzugen Sie möglicherweise die Verwendung von Circumopens in a new tab oder Cairoopens in a new tab.

Wann Zokrates kompilieren

In diesem Programm kompilieren wir die Zokrates-Programme jedes Mal, wenn der Server startetopens in a new tab. Das ist eindeutig eine Verschwendung von Ressourcen, aber dies ist ein Tutorial, das auf Einfachheit optimiert ist.

Wenn ich eine produktionsreife Anwendung schreiben würde, würde ich prüfen, ob ich eine Datei mit den kompilierten Zokrates-Programmen für diese Minenfeldgröße habe, und wenn ja, diese verwenden. Dasselbe gilt für die Bereitstellung eines Verifizierervertrags onchain.

Erstellen der Verifizierer- und Prüferschlüssel

Die Schlüsselerstellungopens in a new tab ist eine weitere reine Berechnung, die für eine gegebene Minenfeldgröße nicht mehr als einmal durchgeführt werden muss. Auch hier wird es aus Gründen der Einfachheit nur einmal gemacht.

Zusätzlich könnten wir eine Setup-Zeremonieopens in a new tab verwenden. Der Vorteil einer Setup-Zeremonie besteht darin, dass man entweder die Entropie oder ein Zwischenergebnis von jedem Teilnehmer benötigt, um beim Zero-Knowledge-Beweis zu betrügen. Wenn mindestens ein Teilnehmer der Zeremonie ehrlich ist und diese Informationen löscht, sind die Zero-Knowledge-Beweise vor bestimmten Angriffen sicher. Es gibt jedoch keinen Mechanismus, um zu überprüfen, ob Informationen von überall gelöscht wurden. Wenn Zero-Knowledge-Beweise von entscheidender Bedeutung sind, sollten Sie an der Setup-Zeremonie teilnehmen.

Hier verlassen wir uns auf perpetual powers of tauopens in a new tab, an denen Dutzende von Teilnehmern beteiligt waren. Es ist wahrscheinlich sicher genug und viel einfacher. Wir fügen auch keine Entropie während der Schlüsselerstellung hinzu, was es den Benutzern erleichtert, die Zero-Knowledge-Konfiguration zu überprüfen.

Wo verifizieren

Wir können die Zero-Knowledge-Beweise entweder onchain (was Gas kostet) oder im Client (mithilfe von verifyopens in a new tab) verifizieren. Ich habe die erste gewählt, da Sie damit den Verifizierer einmal überprüfen und dann darauf vertrauen können, dass er sich nicht ändert, solange die Vertragsadresse dafür gleich bleibt. Wenn die Verifizierung auf dem Client durchgeführt würde, müssten Sie den Code jedes Mal überprüfen, wenn Sie den Client herunterladen.

Auch wenn dieses Spiel Einzelspieler ist, sind viele Blockchain-Spiele Mehrspieler. Onchain-Verifizierung bedeutet, dass Sie den Zero-Knowledge-Beweis nur einmal verifizieren. Wenn man es im Client macht, müsste jeder Client unabhängig verifizieren.

Die Karte in TypeScript oder Zokrates abflachen?

Im Allgemeinen ist es besser, die Verarbeitung in TypeScript durchzuführen, wenn sie entweder in TypeScript oder Zokrates erfolgen kann, da TypeScript viel schneller ist und keine Zero-Knowledge-Beweise erfordert. Dies ist zum Beispiel der Grund, warum wir Zokrates nicht den Hash zur Verfügung stellen und ihn überprüfen lassen, ob er korrekt ist. Das Hashing muss innerhalb von Zokrates erfolgen, aber der Abgleich zwischen dem zurückgegebenen Hash und dem Hash onchain kann außerhalb davon stattfinden.

Allerdings flachen wir die Karte immer noch in Zokrates abopens in a new tab, obwohl wir es auch in TypeScript hätten tun können. Der Grund ist, dass die anderen Optionen meiner Meinung nach schlechter sind.

  • Stellen Sie dem Zokrates-Code ein eindimensionales Array von Booleschen Werten zur Verfügung und verwenden Sie einen Ausdruck wie x*(height+2) +y, um die zweidimensionale Karte zu erhalten. Dies würde den Codeopens in a new tab etwas komplizierter machen, also habe ich entschieden, dass der Leistungsgewinn für ein Tutorial nicht wert ist.

  • Senden Sie Zokrates sowohl das eindimensionale als auch das zweidimensionale Array. Diese Lösung bringt uns jedoch nichts. Der Zokrates-Code müsste überprüfen, ob das bereitgestellte eindimensionale Array wirklich die korrekte Darstellung des zweidimensionalen Arrays ist. Es gäbe also keinen Leistungsgewinn.

  • Flachen Sie das zweidimensionale Array in Zokrates ab. Dies ist die einfachste Option, also habe ich sie gewählt.

Wo Karten speichern

In dieser Anwendung ist gamesInProgressopens in a new tab einfach eine Variable im Speicher. Das bedeutet, dass, wenn Ihr Server ausfällt und neu gestartet werden muss, alle gespeicherten Informationen verloren gehen. Spieler können nicht nur ihr Spiel nicht fortsetzen, sie können nicht einmal ein neues Spiel starten, weil die Onchain-Komponente denkt, dass sie noch ein Spiel im Gange haben.

Dies ist eindeutig ein schlechtes Design für ein Produktionssystem, in dem Sie diese Informationen in einer Datenbank speichern würden. Der einzige Grund, warum ich hier eine Variable verwendet habe, ist, dass dies ein Tutorial ist und Einfachheit die Hauptüberlegung ist.

Fazit: Unter welchen Bedingungen ist dies die geeignete Technik?

So, jetzt wissen Sie, wie man ein Spiel mit einem Server schreibt, der geheime Zustände speichert, die nicht onchain gehören. Aber in welchen Fällen sollten Sie es tun? Es gibt zwei Hauptüberlegungen.

  • Langlaufendes Spiel: Wie oben erwähnt, können Sie in einem kurzen Spiel den Zustand einfach veröffentlichen, sobald das Spiel vorbei ist, und dann alles verifizieren lassen. Aber das ist keine Option, wenn das Spiel eine lange oder unbestimmte Zeit dauert und der Zustand geheim bleiben muss.

  • Etwas Zentralisierung akzeptabel: Zero-Knowledge-Beweise können die Integrität überprüfen, dass eine Entität die Ergebnisse nicht fälscht. Was sie nicht tun können, ist sicherzustellen, dass die Entität weiterhin verfügbar ist und auf Nachrichten antwortet. In Situationen, in denen die Verfügbarkeit auch dezentralisiert sein muss, sind Zero-Knowledge-Beweise keine ausreichende Lösung, und Sie benötigen eine Mehrparteienberechnungopens in a new tab.

Hier finden Sie mehr von meiner Arbeitopens in a new tab.

Anerkennungen

  • Alvaro Alonso las einen Entwurf dieses Artikels und klärte einige meiner Missverständnisse über Zokrates auf.

Alle verbleibenden Fehler liegen in meiner Verantwortung.

Seite zuletzt aktualisiert: 16. Februar 2026

War dieses Tutorial hilfreich?