Verwendung von Zero-Knowledge für einen geheimen Zustand
Es gibt keine Geheimnisse auf der Blockchain. Alles, was auf der Blockchain veröffentlicht wird, ist für jeden offen 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 Minesweeper (opens in a new tab) absolut keinen Sinn, wenn man einfach auf einen Block-Explorer gehen und die Karte sehen kann.
Die einfachste Lösung besteht darin, eine Serverkomponente zu verwenden, um den geheimen Zustand zu speichern. Der Grund, warum wir die Blockchain nutzen, 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 Zustand, der zur Berechnung des Ergebnisses eines Zuges verwendet wurde, der richtige ist.
Nach dem Lesen dieses Artikels werden Sie wissen, wie man einen solchen Server, der einen geheimen Zustand hält, einen Client zur Anzeige des Zustands und eine onchain-Komponente für die Kommunikation zwischen den beiden erstellt. Die wichtigsten Werkzeuge, die wir verwenden werden, sind:
| Werkzeug | Zweck | Verifiziert für Version |
|---|---|---|
| Zokrates (opens in a new tab) | Zero-Knowledge-Beweise und deren Verifizierung | 1.1.9 |
| TypeScript (opens in a new tab) | Programmiersprache für den Server und den Client | 5.4.2 |
| Node (opens in a new tab) | Ausführen des Servers | 20.18.2 |
| Viem (opens in a new tab) | Kommunikation mit der Blockchain | 2.9.20 |
| MUD (opens in a new tab) | Onchain-Datenverwaltung | 2.0.12 |
| React (opens in a new tab) | Client-Benutzeroberfläche | 18.2.0 |
| Vite (opens in a new tab) | Bereitstellen des Client-Codes | 4.2.1 |
Minesweeper-Beispiel
Minesweeper (opens in a new tab) ist ein Spiel, das eine geheime Karte mit einem Minenfeld enthält. Der Spieler wählt eine bestimmte Stelle aus, um dort zu graben. Wenn sich an dieser Stelle eine Mine befindet, ist das Spiel vorbei. Andernfalls erfährt der Spieler die Anzahl der Minen in den acht Feldern, die diese Stelle umgeben.
Diese Anwendung wurde mit MUD (opens in a new tab) geschrieben, einem Framework, das es uns ermöglicht, Daten onchain in einer Schlüssel-Wert-Datenbank (opens in a new tab) zu speichern und diese Daten automatisch mit offchain-Komponenten zu synchronisieren. Neben der Synchronisierung macht es MUD einfach, Zugriffskontrollen bereitzustellen und es anderen Benutzern zu ermöglichen, unsere Anwendung erlaubnisfrei zu erweitern (opens in a new tab).
Ausführen des Minesweeper-Beispiels
Um das Minesweeper-Beispiel auszuführen:
-
Stellen Sie sicher, dass Sie die Voraussetzungen installiert haben (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) undmprocs(opens in a new tab). -
Klonen Sie das Repository.
git clone https://github.com/qbzzt/20240901-secret-state.git -
Installieren Sie die Pakete.
cd 20240901-secret-state/ pnpm install npm install -g mprocsWenn Foundry als Teil von
pnpm installinstalliert wurde, müssen Sie die Kommandozeile neu starten. -
Kompilieren Sie die Verträge
cd packages/contracts forge build cd ../.. -
Starten Sie das Programm (einschließlich einer Anvil (opens in a new tab)-Blockchain) und warten Sie.
mprocsBeachten Sie, dass der Startvorgang lange dauert. Um den Fortschritt zu sehen, verwenden Sie zunächst die Pfeiltaste nach unten, um zum Tab contracts zu scrollen und zu sehen, wie die MUD-Verträge bereitgestellt werden. Wenn Sie die Nachricht Waiting for file changes… erhalten, sind die Verträge bereitgestellt und der weitere Fortschritt findet im Tab 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 Seite und der Konsolenausgabe für den aktuell ausgewählten Prozess auf der rechten Seite.Wenn es ein Problem mit
mprocsgibt, können Sie die vier Prozesse manuell ausführen, jeden in seinem eigenen Kommandozeilenfenster:-
Anvil
cd packages/contracts anvil --base-fee 0 --block-time 2 -
Verträge
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
-
-
Nun können Sie zu dem Client (opens in a new tab) navigieren, auf New Game klicken und anfangen zu spielen.
Tabellen
Wir benötigen mehrere Tabellen (opens in a new tab) onchain.
-
Configuration: Diese Tabelle ist ein Singleton, sie hat keinen Schlüssel und nur einen einzigen Datensatz. Sie wird verwendet, um Spielkonfigurationsinformationen zu speichern:height: Die Höhe eines Minenfeldswidth: Die Breite eines MinenfeldsnumberOfBombs: Die Anzahl der Bomben in jedem Minenfeld
-
VerifierAddress: Diese Tabelle ist ebenfalls ein Singleton. Sie wird verwendet, um einen Teil der Konfiguration zu speichern, nämlich die Adresse des Verifizierer-Vertrags (verifier). Wir hätten diese Informationen in die TabelleConfigurationaufnehmen können, aber sie wird von einer anderen Komponente, dem Server, festgelegt, sodass es einfacher ist, sie in einer separaten Tabelle abzulegen. -
PlayerGame: Der Schlüssel ist die Adresse des Spielers. Die Daten sind:gameId: Ein 32-Byte-Wert, der der Hash der Karte ist, auf der der Spieler spielt (die Spielkennung).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, vongameIdzur Adresse des Spielers. -
Map: Der Schlüssel ist ein Tupel aus drei Werten:gameId: Ein 32-Byte-Wert, der der Hash der Karte ist, auf der der Spieler spielt (die Spielkennung).x-Koordinatey-Koordinate
Der Wert ist eine einzelne Zahl. Er 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 unterscheiden zwischen „der Spieler hat hier noch nicht gegraben“ und „der Spieler hat hier gegraben und festgestellt, dass es in der Umgebung null Bomben gibt“.
Darüber hinaus erfolgt die Kommunikation zwischen dem Client und dem Server über die onchain-Komponente. Dies wird ebenfalls mithilfe von Tabellen implementiert.
PendingGame: Unbearbeitete Anfragen zum Starten eines neuen Spiels.PendingDig: Unbearbeitete Anfragen, an einer bestimmten Stelle in einem bestimmten Spiel zu graben. Dies ist eine offchain-Tabelle (opens in a new tab), was bedeutet, dass sie nicht in den EVM-Speicher geschrieben wird, sondern nur offchain mithilfe von Ereignissen 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, passieren folgende Schritte:
-
mprocs(opens in a new tab) führt vier Komponenten aus:- Anvil (opens in a new tab), das eine lokale Blockchain ausführt
- Verträge (opens in a new tab), das die Verträge für MUD kompiliert (falls erforderlich) und bereitstellt
- Client (opens in a new tab), das Vite (opens in a new tab) ausführt, um die Benutzeroberfläche und den Client-Code für Webbrowser bereitzustellen.
- Server (opens in a new tab), der die Serveraktionen ausführt
-
Das Paket
contractsstellt die MUD-Verträge bereit und führt dann das SkriptPostDeploy.s.sol(opens in a new tab) aus. Dieses Skript legt die Konfiguration fest. Der Code von GitHub spezifiziert ein 10x5-Minenfeld mit acht Minen darin (opens in a new tab). -
Der Server (opens in a new tab) beginnt mit der Einrichtung von MUD (opens in a new tab). Dies aktiviert unter anderem die Datensynchronisierung, sodass eine Kopie der relevanten Tabellen im Speicher des Servers vorhanden ist.
-
Der Server abonniert eine Funktion, die ausgeführt wird, wenn sich die Tabelle
Configurationändert (opens in a new tab). Diese Funktion (opens in a new tab) wird aufgerufen, nachdemPostDeploy.s.solausgeführt wurde und die Tabelle modifiziert hat. -
Wenn die Server-Initialisierungsfunktion die Konfiguration hat, ruft sie
zkFunctionsauf (opens 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 Minenfelds als Konstanten haben müssen. -
Nachdem der Zero-Knowledge-Teil des Servers initialisiert wurde, besteht der nächste Schritt darin, den Zero-Knowledge-Verifizierungsvertrag auf der Blockchain bereitzustellen (opens in a new tab) und die Verifizierer-Adresse in MUD festzulegen.
-
Schließlich abonnieren wir Aktualisierungen, damit wir sehen, wenn ein Spieler anfordert, entweder ein neues Spiel zu starten (opens in a new tab) oder in einem bestehenden Spiel zu graben (opens in a new tab).
Neues Spiel
Folgendes passiert, wenn der Spieler ein neues Spiel anfordert.
-
Wenn für diesen Spieler kein Spiel im Gange ist oder es eines gibt, aber mit einer gameId von null, zeigt der Client einen Button für ein neues Spiel (opens in a new tab) an. Wenn der Benutzer diesen Button drückt, führt React die Funktion
newGameaus (opens in a new tab). -
newGame(opens in a new tab) ist einSystem-Aufruf. In MUD werden alle Aufrufe über den VertragWorldgeleitet, und in den meisten Fällen rufen Sie<namespace>__<function name>auf. In diesem Fall geht der Aufruf anapp__newGame, den MUD dann annewGameinGameSystem(opens in a new tab) weiterleitet. -
Die onchain-Funktion überprüft, ob der Spieler kein laufendes Spiel hat, und wenn keines vorhanden ist, fügt sie die Anfrage zur Tabelle
PendingGamehinzu (opens in a new tab). -
Der Server erkennt die Änderung in
PendingGameund führt die abonnierte Funktion aus (opens in a new tab). Diese Funktion ruftnewGame(opens in a new tab) auf, was wiederumcreateGame(opens in a new tab) aufruft. -
Das Erste, was
createGametut, ist eine zufällige Karte mit der entsprechenden Anzahl von Minen zu erstellen (opens in a new tab). Dann ruft esmakeMapBorders(opens in a new tab) auf, um eine Karte mit leeren Rändern zu erstellen, was für Zokrates notwendig ist. Schließlich ruftcreateGamecalculateMapHashauf, um den Hash der Karte zu erhalten, der als Spiel-ID verwendet wird. -
Die Funktion
newGamefügt das neue Spiel zugamesInProgresshinzu. -
Das Letzte, was der Server tut, ist
app__newGameResponse(opens in a new tab) aufzurufen, was onchain ist. Diese Funktion befindet sich in einem anderenSystem,ServerSystem(opens in a new tab), um die Zugriffskontrolle zu ermöglichen. Die Zugriffskontrolle ist in der MUD-Konfigurationsdatei (opens in a new tab),mud.config.ts(opens in a new tab), definiert.Die Zugriffsliste erlaubt nur einer einzigen Adresse, den
Systemaufzurufen. Dies beschränkt den Zugriff auf die Serverfunktionen auf eine einzige Adresse, sodass sich niemand als Server ausgeben kann. -
Die onchain-Komponente aktualisiert die relevanten Tabellen:
- Erstellen des Spiels in
PlayerGame. - Festlegen der umgekehrten Zuordnung in
GamePlayer. - Entfernen der Anfrage aus
PendingGame.
- Erstellen des Spiels in
-
Der Server identifiziert die Änderung in
PendingGame, unternimmt jedoch nichts, dawantsGame(opens in a new tab) falsch ist. -
Auf dem Client wird
gameRecord(opens in a new tab) auf den EintragPlayerGamefür die Adresse des Spielers gesetzt. Wenn sichPlayerGameändert, ändert sich auchgameRecord. -
Wenn ein Wert in
gameRecordvorhanden ist und das Spiel weder gewonnen noch verloren wurde, zeigt der Client die Karte an (opens in a new tab).
Graben
-
Der Spieler klickt auf den Button der Kartenzelle (opens in a new tab), was die Funktion
dig(opens in a new tab) aufruft. Diese Funktion ruftdigonchain (opens in a new tab) auf. -
Die onchain-Komponente führt eine Reihe von Plausibilitätsprüfungen durch (opens in a new tab) und fügt bei Erfolg die Grabanfrage zu
PendingDig(opens in a new tab) hinzu. -
Der Server erkennt die Änderung in
PendingDig(opens in a new tab). Wenn sie gültig ist (opens in a new tab), ruft er den Zero-Knowledge-Code auf (opens in a new tab) (unten erklärt), um sowohl das Ergebnis als auch einen Beweis für dessen Gültigkeit zu generieren. -
Der Server (opens in a new tab) ruft
digResponse(opens in a new tab) onchain auf. -
digResponsetut zwei Dinge. Zuerst überprüft es den Zero-Knowledge-Beweis (opens in a new tab). Wenn der Beweis dann gültig ist, ruft esprocessDigResult(opens in a new tab) auf, um das Ergebnis tatsächlich zu verarbeiten. -
processDigResultüberprüft, ob das Spiel verloren (opens in a new tab) oder gewonnen (opens in a new tab) wurde, und aktualisiertMap, die onchain-Karte (opens in a new tab). -
Der Client übernimmt die Aktualisierungen automatisch und aktualisiert die dem Spieler angezeigte Karte (opens in a new tab) und teilt dem Spieler gegebenenfalls mit, ob es sich um einen Sieg oder eine Niederlage handelt.
Verwendung von Zokrates
In den oben erklärten Abläufen haben wir die Zero-Knowledge-Teile übersprungen und sie als Blackbox behandelt. Lassen Sie uns diese nun öffnen und sehen, wie dieser Code geschrieben ist.
Hashing der Karte
Wir können diesen JavaScript-Code (opens in a new tab) verwenden, um Poseidon (opens in a new tab) zu implementieren, die von uns verwendete Zokrates-Hashfunktion. Obwohl dies schneller wäre, wäre es auch komplizierter, als einfach die Zokrates-Hashfunktion dafür zu verwenden. Dies ist ein Tutorial, daher ist der Code auf Einfachheit und nicht auf Leistung optimiert. Deshalb benötigen wir zwei verschiedene Zokrates-Programme: eines, um nur den Hash einer Karte zu berechnen (hash), und eines, um tatsächlich einen Zero-Knowledge-Beweis für das Ergebnis der Grabung an einem Ort auf der Karte zu erstellen (dig).
Die Hashfunktion
Dies ist die Funktion, die den Hash einer Karte berechnet. Wir werden diesen Code Zeile für Zeile durchgehen.
import "hashes/poseidon/poseidon.zok" as poseidon;
import "utils/pack/bool/pack128.zok" as pack128;
Diese beiden Zeilen importieren zwei Funktionen aus der Zokrates-Standardbibliothek (opens in a new tab). Die erste Funktion (opens in a new tab) ist ein Poseidon-Hash (opens in a new tab). Sie nimmt ein Array von field-Elementen (opens in a new tab) entgegen und gibt ein field zurück.
Das Feldelement in Zokrates ist typischerweise weniger als 256 Bit lang, 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, wobei wir in jedem Feld nur 128 Bit verwenden. Die Funktion pack128 (opens in a new tab) wandelt zu diesem Zweck ein Array von 128 Bit in ein field um.
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(ean)-Array. Die Größe der Karte beträgt 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-Strings (opens 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 rundherum einen Rand von der Breite eines Feldes ohne Bomben, weshalb wir zwei zur Breite und Höhe addieren müssen.
Der Rückgabewert ist ein field, das den Hash enthält.
bool[512] mut map1d = [false; 512];
Die Karte ist zweidimensional. Die Funktion pack128 funktioniert jedoch nicht mit zweidimensionalen Arrays. Daher flachen wir die Karte zunächst mit map1d zu einem 512-Byte-Array ab. Standardmäßig sind Zokrates-Variablen Konstanten, aber wir müssen diesem Array in einer Schleife Werte zuweisen, also definieren wir es als mut (opens in a new tab).
Wir müssen das Array initialisieren, da Zokrates kein undefined hat. Der Ausdruck [false; 512] bedeutet ein Array von 512 false-Werten (opens in a new tab).
u32 mut counter = 0;
Wir benötigen auch einen Zähler, um zwischen den Bits zu unterscheiden, die wir bereits in map1d gefüllt haben, und denen, die wir noch nicht gefüllt haben.
for u32 x in 0..${width+2} {
So deklarieren Sie eine for-Schleife (opens 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 Konstante zur Kompilierzeit, da width vom TypeScript-Code festgelegt wird, bevor er den Compiler aufruft.
for u32 y in 0..${height+2} {
map1d[counter] = map[x][y];
counter = counter+1;
}
}
Fügen Sie für jeden Ort auf der Karte diesen Wert in das map1d-Array ein und erhöhen Sie den Zähler.
field[4] hashMe = [
pack128(map1d[0..128]),
pack128(map1d[128..256]),
pack128(map1d[256..384]),
pack128(map1d[384..512])
];
Das pack128 erstellt ein Array von vier field-Werten aus map1d. In Zokrates bedeutet array[a..b] den Abschnitt des Arrays, der bei a beginnt und bei b-1 endet.
return poseidon(hashMe);
}
Verwenden Sie poseidon, um dieses Array in einen Hash umzuwandeln.
Das Hash-Programm
Der Server muss hashMap direkt aufrufen, um Spielkennungen zu erstellen. Zokrates kann jedoch nur die Funktion main in einem Programm aufrufen, um zu starten, also erstellen wir ein Programm mit einer main, die die Hashfunktion aufruft.
${hashFragment}
def main(bool[${width+2}][${height+2}] map) -> field {
return hashMap(map);
}
Das Grabungsprogramm
Dies ist das Herzstück des Zero-Knowledge-Teils der Anwendung, in dem wir die Beweise erstellen, die zur Verifizierung der Grabungsergebnisse verwendet werden.
${hashFragment}
// Die Anzahl der Minen am Ort (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 };
}
Warum ein Kartenrand
Zero-Knowledge-Beweise verwenden arithmetische Schaltkreise (opens in a new tab), die kein einfaches Äquivalent zu einer if-Anweisung haben. Stattdessen verwenden sie das Äquivalent des bedingten Operators (opens 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.
Aus diesem Grund wertet eine Zokrates-if-Anweisung immer beide Zweige aus. Wenn Sie beispielsweise diesen Code haben:
bool[5] arr = [false; 5];
u32 index=10;
return if index>4 { 0 } else { arr[index] }
Er wird einen Fehler ausgeben, da er arr[10] berechnen muss, obwohl dieser Wert später mit null multipliziert wird.
Dies ist der Grund, warum wir rund um die Karte einen Rand von der Breite eines Feldes benötigen. Wir müssen die Gesamtzahl der Minen um einen Ort herum berechnen, und das bedeutet, dass wir den Ort eine Reihe darüber und darunter, links und rechts von dem Ort sehen müssen, an dem wir graben. Das bedeutet, dass diese Orte in dem Karten-Array existieren müssen, das Zokrates zur Verfügung gestellt wird.
def 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 es fünf Minen um eine Stelle herum gibt, es sei denn, Sie wissen tatsächlich, um welche Stelle es sich handelt (und Sie können sie nicht einfach mit Ihrer Anfrage abgleichen, da der Beweiser dann andere Werte verwenden und Ihnen nichts davon erzählen könnte). Wir müssen die Karte jedoch geheim halten, während wir sie Zokrates zur Verfügung stellen. Die Lösung besteht darin, einen private-Parameter zu verwenden, der durch den Beweis nicht offengelegt wird.
Dies eröffnet eine weitere Möglichkeit für Missbrauch. Der Beweiser könnte die korrekten 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 einschließen, der die Spielkennung ist.
return (hashMap(map),
Der Rückgabewert hier ist ein Tupel, das sowohl das Karten-Hash-Array als auch das Grabungsergebnis enthält.
if map2mineCount(map, x, y) > 0 { 0xFF } else {
Wir verwenden 255 als speziellen Wert für den Fall, dass der Ort selbst eine Bombe hat.
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)
}
);
}
Wenn der Spieler keine Mine getroffen hat, addieren Sie die Minenanzahl für den Bereich um den Ort und geben Sie diese zurück.
Verwendung von Zokrates aus TypeScript
Zokrates verfügt über eine Befehlszeilenschnittstelle, aber in diesem Programm verwenden wir es im TypeScript-Code (opens in a new tab).
Die Bibliothek, die die Zokrates-Definitionen enthält, heißt zero-knowledge.ts (opens in a new tab).
import { initialize as zokratesInitialize } from "zokrates-js"
Importieren Sie die Zokrates-JavaScript-Bindings (opens in a new tab). Wir benötigen nur die Funktion initialize (opens in a new tab), da sie ein Promise zurückgibt, das in alle Zokrates-Definitionen aufgelöst wird.
export const zkFunctions = async (width: number, height: number) : Promise<any> => {
Ähnlich wie bei Zokrates selbst exportieren wir auch nur eine Funktion, die ebenfalls asynchron (opens in a new tab) ist. Wenn sie schließlich zurückkehrt, stellt sie mehrere Funktionen bereit, wie wir unten sehen werden.
const zokrates = await zokratesInitialize()
Initialisieren Sie Zokrates und holen Sie alles, was wir benötigen, aus der Bibliothek.
const hashFragment = `
import "utils/pack/bool/pack128.zok" as pack128;
import "hashes/poseidon/poseidon.zok" as poseidon;
.
.
.
}
`
const hashProgram = `
${hashFragment}
.
.
.
`
const digProgram = `
${hashFragment}
.
.
.
`
Als Nächstes haben wir die Hashfunktion und zwei Zokrates-Programme, die wir oben gesehen haben.
const digCompiled = zokrates.compile(digProgram)
const hashCompiled = zokrates.compile(hashProgram)
Hier kompilieren wir diese Programme.
// Erstelle die Schlüssel für die Zero-Knowledge-Verifizierung.
// In einem Produktionssystem sollten Sie eine Setup-Zeremonie verwenden.
// (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
Auf einem Produktionssystem würden wir vielleicht eine kompliziertere Setup-Zeremonie (opens in a new tab) verwenden, aber für eine Demonstration ist dies gut genug. Es ist kein Problem, dass die Benutzer den Beweiser-Schlüssel kennen können – sie können ihn trotzdem nicht verwenden, um Dinge zu beweisen, es sei denn, sie sind wahr. Da wir die Entropie (den zweiten Parameter, "") angeben, werden die Ergebnisse immer gleich sein.
Hinweis: Die Kompilierung von Zokrates-Programmen und die Schlüsselerstellung sind langsame Prozesse. Es ist nicht nötig, sie jedes Mal zu wiederholen, sondern nur, wenn sich die Kartengröße ändert. Auf einem Produktionssystem würden Sie sie einmal durchführen und dann die Ausgabe speichern. Der einzige Grund, warum ich das hier nicht tue, ist der Einfachheit halber.
calculateMapHash
const calculateMapHash = function (hashMe: boolean[][]): string {
return (
"0x" +
BigInt(zokrates.computeWitness(hashCompiled, [hashMe]).output.slice(1, -1))
.toString(16)
.padStart(64, "0")
)
}
Die Funktion computeWitness (opens in a new tab) führt das Zokrates-Programm tatsächlich aus. Sie gibt eine Struktur mit zwei Feldern zurück: output, was die Ausgabe des Programms als JSON-String ist, und witness, was die Informationen sind, die benötigt werden, um einen Zero-Knowledge-Beweis für das Ergebnis zu erstellen. 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 Hexadezimalzahl 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 ein BigInt (opens in a new tab) umzuwandeln. .toString(16) konvertiert dieses BigInt in einen hexadezimalen String, und "0x"+ fügt die Markierung für Hexadezimalzahlen hinzu.
// Grabe und gib einen Zero-Knowledge-Beweis des Ergebnisses zurück
// (serverseitiger Code)
Der Zero-Knowledge-Beweis enthält die öffentlichen Eingaben (x und y) und Ergebnisse (Hash der Karte und Anzahl der Bomben).
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")
Es ist ein Problem, in Zokrates zu überprüfen, ob ein Index außerhalb der Grenzen liegt, also tun wir es hier.
const runResults = zokrates.computeWitness(digCompiled, [map, `${x}`, `${y}`])
Führen Sie das Grabungsprogramm aus.
const proof = zokrates.generateProof(
digCompiled.program,
runResults.witness,
proverKey)
return proof
}
Verwenden Sie generateProof (opens in a new tab) und geben Sie den Beweis zurück.
const solidityVerifier = `
// Map size: ${width} x ${height}
\n${zokrates.exportSolidityVerifier(verifierKey)}
`
Ein Solidity-Verifizierer, ein Smart Contract, den wir auf der Blockchain bereitstellen und verwenden können, um von digCompiled.program generierte Beweise zu verifizieren.
return {
zkDig,
calculateMapHash,
solidityVerifier,
}
}
Geben Sie schließlich alles zurück, was anderer Code benötigen könnte.
Sicherheitstests
Sicherheitstests sind wichtig, da sich ein Funktionsfehler irgendwann von selbst zeigt. Wenn die Anwendung jedoch unsicher ist, bleibt dies wahrscheinlich lange Zeit verborgen, bevor es dadurch aufgedeckt wird, dass jemand betrügt und mit Ressourcen davonkommt, die anderen gehören.
Berechtigungen
Es gibt in diesem Spiel eine privilegierte Entität: den Server. Er ist der einzige Benutzer, der die Funktionen in ServerSystem (opens in a new tab) aufrufen darf. Wir können cast (opens in a new tab) verwenden, um zu überprüfen, ob Aufrufe von erlaubnispflichtigen Funktionen nur über das Server-Konto zulässig sind.
Der private Schlüssel des Servers befindet sich in setupNetwork.ts (opens in a new tab).
-
Richte auf dem Computer, auf dem
anvil(die Blockchain) ausgeführt wird, diese Umgebungsvariablen ein.WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d -
Verwende
cast, um zu versuchen, die Verifizierer-Adresse als eine nicht autorisierte Adresse festzulegen.cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $UNAUTHORIZED_KEYNicht nur meldet
casteinen Fehler, sondern du kannst auch die MUD Dev Tools im Spiel im Browser öffnen, auf Tables klicken und app__VerifierAddress auswählen. Du wirst sehen, dass die Adresse nicht null ist. -
Lege die Verifizierer-Adresse als die Adresse des Servers fest.
cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $AUTHORIZED_KEYDie Adresse in app__VerifiedAddress sollte nun null sein.
Alle MUD-Funktionen im selben System durchlaufen dieselbe Zugriffskontrolle, daher halte ich diesen Test für ausreichend. Falls nicht, kannst du die anderen Funktionen in ServerSystem (opens in a new tab) überprüfen.
Zero-Knowledge-Missbrauch
Die Mathematik zur Verifizierung von Zokrates sprengt den Rahmen dieses Tutorials (und meine Fähigkeiten). Wir können jedoch verschiedene Überprüfungen am Zero-Knowledge-Code durchführen, um sicherzustellen, dass er fehlschlägt, wenn er nicht korrekt ausgeführt wird. Für all diese Tests müssen wir zero-knowledge.ts (opens 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 laufendes Spiel, 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 anzugeben. Dazu gehen wir in zkDig und ändern Zeile 91 (opens in a new tab):
proof.inputs[3] = "0x" + "1".padStart(64, "0")
Das bedeutet, dass wir immer behaupten werden, es gäbe eine Bombe, unabhängig von der richtigen Antwort. Versuche, mit dieser Version zu spielen, und du wirst im Tab server des Bildschirms pnpm dev diesen Fehler sehen:
cause: {
code: 3,
message: 'execution reverted: revert: Zero knowledge verification fail',
data: '0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000
000000000000000000000000000000000000000000000000205a65726f206b6e6f776c6564676520766572696669636174696f6
e206661696c'
},
Diese Art von Betrug schlägt also fehl.
Falscher Beweis
Was passiert, wenn wir die richtigen Informationen bereitstellen, aber einfach die falschen Beweisdaten haben? Ersetze nun Zeile 91 durch:
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")],
}
Es schlägt immer noch fehl, aber jetzt ohne Angabe von Gründen, da es während des Verifizierer-Aufrufs passiert.
Wie kann ein Benutzer den Zero-Trust-Code verifizieren?
Smart Contracts sind relativ einfach zu verifizieren. Normalerweise veröffentlicht der Entwickler den Quellcode in einem Block-Explorer, und der Block-Explorer verifiziert, dass der Quellcode zu dem Code in der Transaktion zur Vertragsbereitstellung kompiliert wird. Im Fall von MUD-Systems ist dies etwas komplizierter (opens in a new tab), aber nicht viel.
Bei Zero-Knowledge ist dies schwieriger. Der Verifizierer enthält einige Konstanten und führt Berechnungen mit ihnen durch. Das sagt dir nicht, was bewiesen wird.
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)]);
Die Lösung besteht – zumindest bis Block-Explorer dazu kommen, die Zokrates-Verifizierung in ihre Benutzeroberflächen aufzunehmen – darin, dass die Anwendungsentwickler die Zokrates-Programme zur Verfügung stellen und zumindest einige Benutzer sie selbst mit dem entsprechenden Verifizierungsschlüssel kompilieren.
Um dies zu tun:
-
Erstelle eine Datei,
dig.zok, mit dem Zokrates-Programm. Der folgende Code geht davon aus, dass du die ursprüngliche Kartengröße von 10x5 beibehalten hast.import "utils/pack/bool/pack128.zok" as pack128; import "hashes/poseidon/poseidon.zok" as poseidon; def hashMap(bool[12][7] map) -> field { bool[512] mut map1d = [false; 512]; u32 mut counter = 0; for u32 x in 0..12 { for u32 y in 0..7 { map1d[counter] = map[x][y]; counter = counter+1; } } field[4] hashMe = [ pack128(map1d[0..128]), pack128(map1d[128..256]), pack128(map1d[256..384]), pack128(map1d[384..512]) ]; return poseidon(hashMe); } // Die Anzahl der Minen an der Position (x,y) def map2mineCount(bool[12][7] map, u32 x, u32 y) -> u8 { return if map[x+1][y+1] { 1 } else { 0 }; } def main(private bool[12][7] map, u32 x, u32 y) -> (field, u8) { return (hashMap(map) , if map2mineCount(map, x, y) > 0 { 0xFF } else { 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) } ); } -
Kompiliere den Zokrates-Code und erstelle den Verifizierungsschlüssel. Der Verifizierungsschlüssel muss mit derselben Entropie erstellt werden, die im ursprünglichen Server verwendet wurde, in diesem Fall eine leere Zeichenfolge (opens in a new tab).
zokrates compile --input dig.zok zokrates setup -e "" -
Erstelle den Solidity-Verifizierer selbst und verifiziere, dass er funktional identisch mit dem auf der Blockchain ist (der Server fügt einen Kommentar hinzu, aber das ist nicht wichtig).
zokrates export-verifier diff 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 dieser Kompromisse an und warum die aktuelle Lösung anderen Optionen vorzuziehen ist.
Warum Zero-Knowledge
Für Minesweeper benötigt man eigentlich kein Zero-Knowledge. Der Server kann die Karte immer speichern und sie dann einfach komplett aufdecken, wenn das Spiel vorbei ist. Am Ende des Spiels kann der Smart Contract dann den Karten-Hash berechnen, überprüfen, ob er übereinstimmt, und falls nicht, den Server bestrafen oder das Spiel komplett ignorieren.
Ich habe diese einfachere Lösung nicht verwendet, da sie nur für kurze Spiele mit einem klar definierten Endzustand funktioniert. Wenn ein Spiel potenziell unendlich ist (wie es bei autonomen Welten (opens in a new tab) der Fall ist), benötigt man eine Lösung, die den Zustand beweist, ohne ihn preiszugeben.
Als Tutorial benötigte dieser Artikel ein kurzes Spiel, das leicht zu verstehen ist, aber diese Technik ist am nützlichsten für längere Spiele.
Warum Zokrates?
Zokrates (opens in a new tab) ist nicht die einzige verfügbare Zero-Knowledge-Bibliothek, aber es ähnelt einer normalen, imperativen (opens in a new tab) Programmiersprache und unterstützt boolesche Variablen.
Für Ihre Anwendung mit anderen Anforderungen ziehen Sie es vielleicht vor, Circum (opens in a new tab) oder Cairo (opens in a new tab) zu verwenden.
Wann Zokrates kompiliert werden sollte
In diesem Programm kompilieren wir die Zokrates-Programme jedes Mal, wenn der Server startet (opens in a new tab). Das ist eindeutig eine Ressourcenverschwendung, aber dies ist ein Tutorial, das auf Einfachheit optimiert ist.
Wenn ich eine Anwendung für den produktiven Einsatz 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. Das Gleiche gilt für die Bereitstellung eines Verifizierer-Vertrags onchain.
Erstellen der Verifizierer- und Beweiser-Schlüssel
Die Schlüsselerstellung (opens in a new tab) ist eine weitere reine Berechnung, die für eine bestimmte Minenfeldgröße nicht mehr als einmal durchgeführt werden muss. Auch hier wird sie der Einfachheit halber nur einmal durchgeführt.
Zusätzlich könnten wir eine Setup-Zeremonie (opens in a new tab) verwenden. Der Vorteil einer Setup-Zeremonie ist, 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 die Informationen ü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 Tau (opens in a new tab), an dem Dutzende von Teilnehmern mitgewirkt haben. Es ist wahrscheinlich sicher genug und viel einfacher. Wir fügen während der Schlüsselerstellung auch keine Entropie hinzu, was es den Benutzern erleichtert, die Zero-Knowledge-Konfiguration zu überprüfen.
Wo verifiziert werden soll
Wir können die Zero-Knowledge-Beweise entweder onchain (was Gas kostet) oder im Client (mit verify (opens in a new tab)) verifizieren. Ich habe mich für Ersteres entschieden, da man so den Verifizierer verifizieren kann und dann darauf vertrauen kann, 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, den Sie erhalten, jedes Mal verifizieren, wenn Sie den Client herunterladen.
Außerdem ist dieses Spiel zwar ein Einzelspieler-Spiel, aber viele Blockchain-Spiele sind Mehrspieler-Spiele. Eine Onchain-Verifizierung bedeutet, dass Sie den Zero-Knowledge-Beweis nur einmal verifizieren. Wenn dies im Client geschieht, müsste jeder Client unabhängig verifizieren.
Die Karte in TypeScript oder Zokrates abflachen?
Im Allgemeinen ist es besser, wenn die Verarbeitung entweder in TypeScript oder in Zokrates erfolgen kann, sie in TypeScript durchzuführen, was viel schneller ist und keine Zero-Knowledge-Beweise erfordert. Das 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 stattfinden.
Dennoch flachen wir die Karte in Zokrates ab (opens in a new tab), obwohl wir es in TypeScript hätten tun können. Der Grund dafür ist, dass die anderen Optionen meiner Meinung nach schlechter sind.
-
Dem Zokrates-Code ein eindimensionales Array von Booleschen Werten zur Verfügung stellen und einen Ausdruck wie
x*(height+2) +yverwenden, um die zweidimensionale Karte zu erhalten. Dies würde den Code (opens in a new tab) etwas komplizierter machen, also habe ich entschieden, dass der Leistungsgewinn für ein Tutorial nicht lohnenswert ist. -
Zokrates sowohl das eindimensionale als auch das zweidimensionale Array senden. 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.
-
Das zweidimensionale Array in Zokrates abflachen. Dies ist die einfachste Option, also habe ich sie gewählt.
Wo Karten gespeichert werden sollen
In dieser Anwendung ist gamesInProgress (opens in a new tab) einfach eine Variable im Speicher. Das bedeutet, dass alle gespeicherten Informationen verloren gehen, wenn Ihr Server abstürzt und neu gestartet werden muss. Die Spieler können nicht nur ihr Spiel nicht fortsetzen, sie können nicht einmal ein neues Spiel beginnen, da die Onchain-Komponente denkt, dass sie noch ein laufendes Spiel haben.
Dies ist eindeutig ein schlechtes Design für ein produktives System, in dem man diese Informationen in einer Datenbank speichern würde. Der einzige Grund, warum ich hier eine Variable verwendet habe, ist, dass dies ein Tutorial ist und Einfachheit im Vordergrund steht.
Fazit: Unter welchen Bedingungen ist dies die geeignete Technik?
Sie wissen nun also, wie man ein Spiel mit einem Server schreibt, der einen geheimen Zustand speichert, der nicht onchain gehört. Aber in welchen Fällen sollten Sie das tun? Es gibt zwei Hauptüberlegungen.
-
Lang laufendes Spiel: Wie oben erwähnt, können Sie bei einem kurzen Spiel den Zustand einfach veröffentlichen, sobald das Spiel vorbei ist, und dann alles verifizieren lassen. Das ist jedoch keine Option, wenn das Spiel eine lange oder unbestimmte Zeit dauert und der Zustand geheim bleiben muss.
-
Gewisse Zentralisierung akzeptabel: Zero-Knowledge-Beweise können die Integrität verifizieren, also dass eine Entität die Ergebnisse nicht fälscht. Was sie nicht können, ist sicherzustellen, dass die Entität weiterhin verfügbar ist und auf Nachrichten antwortet. In Situationen, in denen auch die Verfügbarkeit dezentral sein muss, sind Zero-Knowledge-Beweise keine ausreichende Lösung, und Sie benötigen Multi-Party Computation (opens in a new tab).
Weitere meiner Arbeiten finden Sie hier (opens in a new tab).
Danksagungen
- Alvaro Alonso hat einen Entwurf dieses Artikels gelesen und einige meiner Missverständnisse bezüglich Zokrates geklärt.
Alle verbleibenden Fehler liegen in meiner Verantwortung.
