Passer au contenu principal

Utiliser la preuve à divulgation nulle de connaissance pour un état secret

serveur
hors-chaîne
centralisé
preuve à divulgation nulle de connaissance
zokrates
mud
Avancé
Ori Pomerantz
15 mars 2025
31 minutes de lecture

Il n'y a pas de secret sur la blockchain. Tout ce qui est publié sur la blockchain est ouvert à la lecture de tous. C'est nécessaire, car la blockchain est basée sur le fait que tout le monde peut la vérifier. Cependant, les jeux reposent souvent sur un état secret. Par exemple, le jeu du démineur (opens in a new tab) n'a absolument aucun sens si vous pouvez simplement aller sur un explorateur de blockchain et voir la carte.

La solution la plus simple est d'utiliser un composant serveur pour contenir l'état secret. Cependant, la raison pour laquelle nous utilisons la blockchain est d'empêcher la triche par le développeur du jeu. Nous devons garantir l'honnêteté du composant serveur. Le serveur peut fournir un hachage de l'état, et utiliser des preuves à divulgation nulle de connaissance pour prouver que l'état utilisé pour calculer le résultat d'un mouvement est le bon.

Après avoir lu cet article, vous saurez comment créer ce type de serveur de maintien d'état secret, un client pour montrer l'état, et un composant en chaîne pour la communication entre les deux. Les principaux outils que nous utiliserons seront :

OutilObjectifVérifié sur la version
Zokrates (opens in a new tab)Preuves à divulgation nulle de connaissance et leur vérification1.1.9
Typescript (opens in a new tab)Langage de programmation pour le serveur et le client5.4.2
Node (opens in a new tab)Exécution du serveur20.18.2
Viem (opens in a new tab)Communication avec la Blockchain2.9.20
MUD (opens in a new tab)Gestion des données en chaîne2.0.12
React (opens in a new tab)Interface utilisateur du client18.2.0
Vite (opens in a new tab)Servir le code client4.2.1

Exemple de démineur

Le démineur (opens in a new tab) est un jeu qui comprend une carte secrète avec un champ de mines. Le joueur choisit de creuser à un endroit précis. Si cet emplacement contient une mine, la partie est terminée. Sinon, le joueur obtient le nombre de mines dans les huit cases environnantes.

Cette application est écrite en utilisant MUD (opens in a new tab), un framework qui nous permet de stocker des données en chaîne en utilisant une base de données clé-valeur (opens in a new tab) et de synchroniser ces données automatiquement avec des composants hors chaîne. En plus de la synchronisation, MUD facilite le contrôle d'accès et permet aux autres utilisateurs d'étendre (opens in a new tab) notre application sans permission.

Exécuter l'exemple du démineur

Pour exécuter l'exemple du démineur :

  1. Assurez-vous d'avoir installé les prérequis (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), et mprocs (opens in a new tab).

  2. Cloner le dépôt.

    1git clone https://github.com/qbzzt/20240901-secret-state.git
  3. Installez les paquets.

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

    Si Foundry a été installé dans le cadre de pnpm install, vous devez redémarrer le shell de ligne de commande.

  4. Compiler les contrats

    1cd packages/contracts
    2forge build
    3cd ../..
  5. Démarrez le programme (y compris une blockchain anvil (opens in a new tab)) et attendez.

    1mprocs

    Notez que le démarrage prend beaucoup de temps. Pour voir la progression, utilisez d'abord la flèche vers le bas pour faire défiler jusqu'à l'onglet contracts pour voir les contrats MUD en cours de déploiement. Lorsque vous recevez le message Waiting for file changes…, les contrats sont déployés et la suite de la progression se déroulera dans l'onglet server. Là, vous attendez de recevoir le message Verifier address: 0x.....

    Si cette étape est réussie, vous verrez l'écran mprocs, avec les différents processus à gauche et la sortie de la console pour le processus actuellement sélectionné à droite.

    L'écran mprocs

    En cas de problème avec mprocs, vous pouvez exécuter les quatre processus manuellement, chacun dans sa propre fenêtre de ligne de commande :

    • Anvil

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

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

      1cd packages/server
      2pnpm start
    • Client

      1cd packages/client
      2pnpm run dev
  6. Vous pouvez maintenant naviguer vers le client (opens in a new tab), cliquer sur New Game et commencer à jouer.

Tables

Nous avons besoin de plusieurs tables (opens in a new tab) en chaîne.

  • Configuration : cette table est un singleton, elle n'a pas de clé et un seul enregistrement. Elle est utilisée pour contenir les informations de configuration du jeu :

    • height : la hauteur d'un champ de mines
    • width : la largeur d'un champ de mines
    • numberOfBombs : le nombre de bombes dans chaque champ de mines
  • VerifierAddress: cette table est également un singleton. Elle est utilisée pour contenir une partie de la configuration, l'adresse du contrat de vérification (verifier). Nous aurions pu mettre cette information dans la table Configuration, mais elle est définie par un composant différent, le serveur, il est donc plus facile de la mettre dans une table séparée.

  • PlayerGame : la clé est l'adresse du joueur. Les données sont :

    • gameId : valeur de 32 octets qui est le hachage de la carte sur laquelle le joueur joue (l'identifiant du jeu).
    • win : un booléen indiquant si le joueur a gagné la partie.
    • lose : un booléen indiquant si le joueur a perdu la partie.
    • digNumber : le nombre de creusements réussis dans le jeu.
  • GamePlayer : cette table contient le mappage inverse, de gameId à l'adresse du joueur.

  • Map : la clé est un tuple de trois valeurs :

    • gameId : valeur de 32 octets qui est le hachage de la carte sur laquelle le joueur joue (l'identifiant du jeu).
    • Coordonnée x
    • Coordonnée y

    La valeur est un nombre unique. C'est 255 si une bombe a été détectée. Sinon, c'est le nombre de bombes autour de cet emplacement plus un. Nous ne pouvons pas simplement utiliser le nombre de bombes, car par défaut, tout le stockage dans l'EVM et toutes les valeurs de lignes dans MUD sont à zéro. Nous devons faire la distinction entre « le joueur n'a pas encore creusé ici » et « le joueur a creusé ici, et a trouvé qu'il n'y avait aucune bombe aux alentours ».

De plus, la communication entre le client et le serveur se fait via le composant en chaîne. Ceci est également implémenté en utilisant des tables.

  • PendingGame: demandes non traitées pour démarrer une nouvelle partie.
  • PendingDig: demandes non traitées pour creuser à un endroit spécifique dans un jeu spécifique. Il s'agit d'une table hors chaîne (opens in a new tab), ce qui signifie qu'elle n'est pas écrite dans le stockage EVM, elle n'est lisible qu'en dehors de la chaîne en utilisant des événements.

Flux d'exécution et de données

Ces flux coordonnent l'exécution entre le client, le composant en chaîne et le serveur.

Initialisation

Lorsque vous exécutez mprocs, ces étapes se produisent :

  1. mprocs (opens in a new tab) exécute quatre composants :

  2. Le paquet contracts déploie les contrats MUD puis exécute le script PostDeploy.s.sol (opens in a new tab). Ce script définit la configuration. Le code de github spécifie un champ de mines de 10x5 avec huit mines (opens in a new tab).

  3. Le serveur (opens in a new tab) commence par configurer MUD (opens in a new tab). Entre autres, cela active la synchronisation des données, de sorte qu'une copie des tables pertinentes existe dans la mémoire du serveur.

  4. Le serveur abonne une fonction à exécuter lorsque la table Configuration change (opens in a new tab). Cette fonction (opens in a new tab) est appelée après l'exécution de PostDeploy.s.sol et la modification de la table.

  5. Lorsque la fonction d'initialisation du serveur dispose de la configuration, elle appelle zkFunctions (opens in a new tab) pour initialiser la partie preuve à divulgation nulle de connaissance du serveur. Cela ne peut pas se produire tant que nous n'avons pas la configuration, car les fonctions de preuve à divulgation nulle de connaissance doivent avoir la largeur et la hauteur du champ de mines comme constantes.

  6. Une fois que la partie preuve à divulgation nulle de connaissance du serveur est initialisée, l'étape suivante consiste à déployer le contrat de vérification de la preuve à divulgation nulle de connaissance sur la blockchain (opens in a new tab) et à définir l'adresse du vérifié dans MUD.

  7. Enfin, nous nous abonnons aux mises à jour pour savoir quand un joueur demande soit de commencer une nouvelle partie (opens in a new tab) soit de creuser dans une partie existante (opens in a new tab).

Nouvelle partie

Voici ce qui se passe lorsque le joueur demande une nouvelle partie.

  1. S'il n'y a pas de partie en cours pour ce joueur, ou s'il y en a une mais avec un gameId à zéro, le client affiche un bouton nouvelle partie (opens in a new tab). Lorsque l'utilisateur appuie sur ce bouton, React exécute la fonction newGame (opens in a new tab).

  2. newGame (opens in a new tab) est un appel System. Dans MUD, tous les appels sont acheminés via le contrat World, et dans la plupart des cas, vous appelez <namespace>__<function name>. Dans ce cas, l'appel est à app__newGame, que MUD achemine ensuite vers newGame dans GameSystem (opens in a new tab).

  3. La fonction en chaîne vérifie que le joueur n'a pas de partie en cours, et s'il n'y en a pas, ajoute la demande à la table PendingGame (opens in a new tab).

  4. Le serveur détecte le changement dans PendingGame et exécute la fonction abonnée (opens in a new tab). Cette fonction appelle newGame (opens in a new tab), qui à son tour appelle createGame (opens in a new tab).

  5. La première chose que createGame fait est de créer une carte aléatoire avec le nombre de mines approprié (opens in a new tab). Ensuite, elle appelle makeMapBorders (opens in a new tab) pour créer une carte avec des bordures vides, ce qui est nécessaire pour Zokrates. Enfin, createGame appelle calculateMapHash, pour obtenir le hachage de la carte, qui est utilisé comme ID de jeu.

  6. La fonction newGame ajoute la nouvelle partie à gamesInProgress.

  7. La dernière chose que fait le serveur est d'appeler app__newGameResponse (opens in a new tab), qui est en chaîne. Cette fonction se trouve dans un System différent, ServerSystem (opens in a new tab), pour permettre le contrôle d'accès. Le contrôle d'accès est défini dans le fichier de configuration MUD (opens in a new tab), mud.config.ts (opens in a new tab).

    La liste d'accès n'autorise qu'une seule adresse à appeler le System. Cela restreint l'accès aux fonctions du serveur à une seule adresse, afin que personne ne puisse se faire passer pour le serveur.

  8. Le composant en chaîne met à jour les tables pertinentes :

    • Créer la partie dans PlayerGame.
    • Définir le mappage inverse dans GamePlayer.
    • Supprimer la demande de PendingGame.
  9. Le serveur identifie le changement dans PendingGame, mais ne fait rien car wantsGame (opens in a new tab) est faux.

  10. Sur le client, gameRecord (opens in a new tab) est défini sur l'entrée PlayerGame pour l'adresse du joueur. Lorsque PlayerGame change, gameRecord change aussi.

  11. S'il y a une valeur dans gameRecord, et que la partie n'a été ni gagnée ni perdue, le client affiche la carte (opens in a new tab).

Creuser

  1. Le joueur clique sur le bouton de la cellule de la carte (opens in a new tab), ce qui appelle la fonction dig (opens in a new tab). Cette fonction appelle dig en chaîne (opens in a new tab).

  2. Le composant en chaîne effectue un certain nombre de vérifications de cohérence (opens in a new tab), et en cas de succès, ajoute la demande de creusage à PendingDig (opens in a new tab).

  3. Le serveur détecte le changement dans PendingDig (opens in a new tab). Si elle est valide (opens in a new tab), il appelle le code de preuve à divulgation nulle de connaissance (opens in a new tab) (expliqué ci-dessous) pour générer à la fois le résultat et une preuve de sa validité.

  4. Le serveur (opens in a new tab) appelle digResponse (opens in a new tab) en chaîne.

  5. digResponse fait deux choses. Tout d'abord, il vérifie la preuve à divulgation nulle de connaissance (opens in a new tab). Ensuite, si la preuve est valide, il appelle processDigResult (opens in a new tab) pour traiter réellement le résultat.

  6. processDigResult vérifie si la partie a été perdue (opens in a new tab) ou gagnée (opens in a new tab), et met à jour Map, la carte en chaîne (opens in a new tab).

  7. Le client récupère automatiquement les mises à jour et met à jour la carte affichée au joueur (opens in a new tab), et le cas échéant, informe le joueur s'il s'agit d'une victoire ou d'une défaite.

Utiliser Zokrates

Dans les flux expliqués ci-dessus, nous avons ignoré les parties concernant la preuve à divulgation nulle de connaissance, en les traitant comme une boîte noire. Maintenant, ouvrons-la et voyons comment ce code est écrit.

Hachage de la carte

Nous pouvons utiliser ce code JavaScript (opens in a new tab) pour implémenter Poseidon (opens in a new tab), la fonction de hachage Zokrates que nous utilisons. Cependant, bien que cela serait plus rapide, ce serait aussi plus compliqué que de simplement utiliser la fonction de hachage Zokrates pour le faire. Ceci est un tutoriel, et le code est donc optimisé pour la simplicité, non pour la performance. Par conséquent, nous avons besoin de deux programmes Zokrates différents, un pour calculer simplement le hachage d'une carte (hash) et un pour créer réellement une preuve à divulgation nulle de connaissance du résultat du creusement à un emplacement sur la carte (dig).

La fonction de hachage

C'est la fonction qui calcule le hachage d'une carte. Nous allons parcourir ce code ligne par ligne.

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

Ces deux lignes importent deux fonctions de la bibliothèque standard de Zokrates (opens in a new tab). La première fonction (opens in a new tab) est un hachage Poseidon (opens in a new tab). Elle prend un tableau d'éléments field (opens in a new tab) et renvoie un field.

L'élément field dans Zokrates est généralement inférieur à 256 bits, mais pas de beaucoup. Pour simplifier le code, nous limitons la carte à 512 bits et nous hachons un tableau de quatre champs, et dans chaque champ, nous n'utilisons que 128 bits. La fonction pack128 (opens in a new tab) transforme un tableau de 128 bits en un field à cet effet.

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

Cette ligne commence une définition de fonction. hashMap reçoit un seul paramètre appelé map, un tableau bool(éen) à deux dimensions. La taille de la carte est de width+2 par height+2 pour des raisons qui sont expliquées ci-dessous.

Nous pouvons utiliser ${width+2} et ${height+2} car les programmes Zokrates sont stockés dans cette application sous forme de modèles de chaînes de caractères (opens in a new tab). Le code entre ${ et } est évalué par JavaScript, et de cette manière, le programme peut être utilisé pour différentes tailles de carte. Le paramètre map a une bordure d'un emplacement de large tout autour sans aucune bombe, c'est la raison pour laquelle nous devons ajouter deux à la largeur et à la hauteur.

La valeur de retour est un field qui contient le hachage.

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

La carte est bidimensionnelle. Cependant, la fonction pack128 ne fonctionne pas avec des tableaux bidimensionnels. Nous aplatissons donc d'abord la carte en un tableau de 512 octets, en utilisant map1d. Par défaut, les variables Zokrates sont des constantes, mais nous devons assigner des valeurs à ce tableau dans une boucle, nous le définissons donc comme mut (opens in a new tab).

Nous devons initialiser le tableau car Zokrates n'a pas de undefined. L'expression [false; 512] signifie un tableau de 512 valeurs false (opens in a new tab).

1 u32 mut counter = 0;

Nous avons également besoin d'un compteur pour distinguer les bits que nous avons déjà remplis dans map1d de ceux que nous n'avons pas encore remplis.

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

Voici comment déclarer une boucle for (opens in a new tab) en Zokrates. Une boucle for de Zokrates doit avoir des limites fixes, car bien qu'elle semble être une boucle, le compilateur la « déroule » en réalité. L'expression ${width+2} est une constante de temps de compilation car width est définie par le code TypeScript avant d'appeler le compilateur.

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

Pour chaque emplacement de la carte, mettez cette valeur dans le tableau map1d et incrémentez le compteur.

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

Le pack128 pour créer un tableau de quatre valeurs field à partir de map1d. En Zokrates, array[a..b] signifie la tranche du tableau qui commence à a et se termine à b-1.

1 return poseidon(hashMe);
2}

Utilisez poseidon pour convertir ce tableau en un hachage.

Le programme de hachage

Le serveur doit appeler hashMap directement pour créer des identifiants de jeu. Cependant, Zokrates ne peut appeler que la fonction main d'un programme pour démarrer, nous créons donc un programme avec une fonction main qui appelle la fonction de hachage.

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

Le programme de creusage

C'est le cœur de la partie preuve à divulgation nulle de connaissance de l'application, où nous produisons les preuves qui sont utilisées pour vérifier les résultats des creusages.

1${hashFragment}
2
3// Le nombre de mines à l'emplacement (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}

Pourquoi une bordure de carte

Les preuves à divulgation nulle de connaissance utilisent des circuits arithmétiques (opens in a new tab), qui n'ont pas d'équivalent facile à une instruction if. Au lieu de cela, ils utilisent l'équivalent de l'opérateur conditionnel (opens in a new tab). Si a peut être soit zéro soit un, vous pouvez calculer if a { b } else { c } comme ab+(1-a)c.

Pour cette raison, une instruction if de Zokrates évalue toujours les deux branches. Par exemple, si vous avez ce code :

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

Il générera une erreur, car il a besoin de calculer arr[10], même si cette valeur sera plus tard multipliée par zéro.

C'est la raison pour laquelle nous avons besoin d'une bordure d'un emplacement de large tout autour de la carte. Nous devons calculer le nombre total de mines autour d'un emplacement, ce qui signifie que nous devons voir l'emplacement une ligne au-dessus et en dessous, à gauche et à droite de l'emplacement où nous creusons. Ce qui signifie que ces emplacements doivent exister dans le tableau de la carte qui est fourni à Zokrates.

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

Par défaut, les preuves Zokrates incluent leurs entrées. Il est inutile de savoir qu'il y a cinq mines autour d'un endroit si vous ne savez pas réellement de quel endroit il s'agit (et vous ne pouvez pas simplement le faire correspondre à votre demande, car le prouveur pourrait alors utiliser des valeurs différentes sans vous en informer). Cependant, nous devons garder la carte secrète, tout en la fournissant à Zokrates. La solution est d'utiliser un paramètre private, un paramètre qui n'est pas révélé par la preuve.

Cela ouvre une autre voie d'abus. Le prouveur pourrait utiliser les bonnes coordonnées, mais créer une carte avec un nombre quelconque de mines autour de l'emplacement, et éventuellement à l'emplacement même. Pour empêcher cet abus, nous faisons en sorte que la preuve à divulgation nulle de connaissance inclue le hachage de la carte, qui est l'identifiant de la partie.

1 return (hashMap(map),

La valeur de retour ici est un tuple qui inclut le tableau de hachage de la carte ainsi que le résultat du creusage.

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

Nous utilisons 255 comme valeur spéciale au cas où l'emplacement lui-même contiendrait une bombe.

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}

Si le joueur n'a pas touché de mine, ajoutez les nombres de mines pour la zone autour de l'emplacement et retournez ce résultat.

Utiliser Zokrates depuis TypeScript

Zokrates a une interface en ligne de commande, mais dans ce programme, nous l'utilisons dans le code TypeScript (opens in a new tab).

La bibliothèque qui contient les définitions de Zokrates s'appelle zero-knowledge.ts (opens in a new tab).

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

Importer les liaisons JavaScript de Zokrates (opens in a new tab). Nous n'avons besoin que de la fonction initialize (opens in a new tab) car elle renvoie une promesse qui se résout en toutes les définitions de Zokrates.

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

Similaire à Zokrates lui-même, nous n'exportons qu'une seule fonction, qui est également asynchrone (opens in a new tab). Lorsqu'elle finit par retourner un résultat, elle fournit plusieurs fonctions comme nous le verrons ci-dessous.

1const zokrates = await zokratesInitialize()

Initialiser Zokrates, obtenir tout ce dont nous avons besoin de la bibliothèque.

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 `
Afficher tout

Ensuite, nous avons la fonction de hachage et les deux programmes Zokrates que nous avons vus ci-dessus.

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

Ici, nous compilons ces programmes.

1// Créez les clés pour la vérification à divulgation nulle de connaissance.
2// Sur un système de production, vous voudriez utiliser une cérémonie de configuration.
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

Sur un système de production, nous pourrions utiliser une cérémonie de configuration (opens in a new tab) plus compliquée, mais cela suffit pour une démonstration. Ce n'est pas un problème que les utilisateurs puissent connaître la clé du prouveur - ils ne peuvent toujours pas l'utiliser pour prouver des choses à moins qu'elles ne soient vraies. Parce que nous spécifions l'entropie (le deuxième paramètre, ""), les résultats seront toujours les mêmes.

Note : La compilation des programmes Zokrates et la création des clés sont des processus lents. Il n'est pas nécessaire de les répéter à chaque fois, seulement lorsque la taille de la carte change. Sur un système de production, vous le feriez une fois, puis stockeriez le résultat. La seule raison pour laquelle je ne le fais pas ici est par souci de simplicité.

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}

La fonction computeWitness (opens in a new tab) exécute réellement le programme Zokrates. Elle retourne une structure avec deux champs : output, qui est la sortie du programme sous forme de chaîne JSON, et witness, qui est l'information nécessaire pour créer une preuve à divulgation nulle de connaissance du résultat. Ici, nous avons juste besoin de la sortie.

La sortie est une chaîne de caractères de la forme "31337", un nombre décimal entre guillemets. Mais la sortie dont nous avons besoin pour viem est un nombre hexadécimal de la forme 0x60A7. Donc, nous utilisons .slice(1,-1) pour supprimer les guillemets, puis BigInt pour transformer la chaîne restante, qui est un nombre décimal, en un BigInt (opens in a new tab). .toString(16) convertit ce BigInt en une chaîne hexadécimale, et "0x"+ ajoute le marqueur pour les nombres hexadécimaux.

1// Creuser et retourner une preuve à divulgation nulle de connaissance du résultat
2// (code côté serveur)

La preuve à divulgation nulle de connaissance inclut les entrées publiques (x et y) et les résultats (hachage de la carte et nombre de bombes).

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")

C'est un problème de vérifier si un index est hors limites dans Zokrates, alors nous le faisons ici.

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

Exécutez le programme de creusage.

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

Utilisez generateProof (opens in a new tab) et retournez la preuve.

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

Un vérificateur Solidity, un contrat intelligent que nous pouvons déployer sur la blockchain et utiliser pour vérifier les preuves générées par digCompiled.program.

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

Enfin, retournez tout ce dont d'autres codes pourraient avoir besoin.

Tests de sécurité

Les tests de sécurité sont importants car un bug de fonctionnalité finira par se révéler. Mais si l'application n'est pas sécurisée, cela risque de rester caché pendant longtemps avant d'être révélé par quelqu'un qui triche et s'empare de ressources appartenant à d'autres.

Permissions

Il y a une entité privilégiée dans ce jeu, le serveur. C'est le seul utilisateur autorisé à appeler les fonctions dans ServerSystem (opens in a new tab). Nous pouvons utiliser cast (opens in a new tab) pour vérifier que les appels aux fonctions à permission sont autorisés uniquement en tant que compte serveur.

La clé privée du serveur se trouve dans setupNetwork.ts (opens in a new tab).

  1. Sur l'ordinateur qui exécute anvil (la blockchain), définissez ces variables d'environnement.

    1WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b
    2UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
    3AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
  2. Utilisez cast pour tenter de définir l'adresse du vérificateur en tant qu'adresse non autorisée.

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

    Non seulement cast signale un échec, mais vous pouvez ouvrir les Outils de développement MUD dans le jeu sur le navigateur, cliquer sur Tables et sélectionner app__VerifierAddress. Vous voyez que l'adresse n'est pas zéro.

  3. Définissez l'adresse du vérificateur comme l'adresse du serveur.

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

    L'adresse dans app__VerifiedAddress devrait maintenant être à zéro.

Toutes les fonctions MUD dans le même System passent par le même contrôle d'accès, donc je considère ce test suffisant. Si vous n'êtes pas d'accord, vous pouvez vérifier les autres fonctions dans ServerSystem (opens in a new tab).

Abus de la preuve à divulgation nulle de connaissance

La mathématique pour vérifier Zokrates dépasse le cadre de ce tutoriel (et mes capacités). Cependant, nous pouvons exécuter diverses vérifications sur le code de preuve à divulgation nulle de connaissance pour vérifier que s'il n'est pas fait correctement, il échoue. Tous ces tests vont nous obliger à modifier zero-knowledge.ts (opens in a new tab) et à redémarrer toute l'application. Il ne suffit pas de redémarrer le processus du serveur, car cela met l'application dans un état impossible (le joueur a une partie en cours, mais la partie n'est plus disponible pour le serveur).

Mauvaise réponse

La possibilité la plus simple est de fournir la mauvaise réponse dans la preuve à divulgation nulle de connaissance. Pour ce faire, nous allons dans zkDig et modifions la ligne 91 (opens in a new tab) :

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

Cela signifie que nous prétendrons toujours qu'il y a une bombe, quelle que soit la bonne réponse. Essayez de jouer avec cette version, et vous verrez dans l'onglet server de l'écran pnpm dev cette erreur :

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

Donc, ce genre de triche échoue.

Mauvaise preuve

Que se passe-t-il si nous fournissons les bonnes informations, mais que les données de la preuve sont incorrectes ? Maintenant, remplacez la ligne 91 par :

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}

Cela échoue toujours, mais maintenant cela échoue sans raison car cela se produit pendant l'appel du vérificateur.

Comment un utilisateur peut-il vérifier le code de confiance zéro ?

Les contrats intelligents sont relativement faciles à vérifier. Généralement, le développeur publie le code source sur un explorateur de blocs, et l'explorateur de blocs vérifie que le code source se compile bien en le code dans la transaction de déploiement du contrat. Dans le cas des System MUD, c'est légèrement plus compliqué (opens in a new tab), mais pas de beaucoup.

C'est plus difficile avec la preuve à divulgation nulle de connaissance. Le vérificateur inclut quelques constantes et exécute des calculs sur celles-ci. Cela ne vous dit pas ce qui est prouvé.

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

La solution, du moins jusqu'à ce que les explorateurs de blocs ajoutent la vérification Zokrates à leurs interfaces utilisateur, est que les développeurs d'applications mettent à disposition les programmes Zokrates, et qu'au moins certains utilisateurs les compilent eux-mêmes avec la clé de vérification appropriée.

Pour ce faire :

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

  2. Créez un fichier, dig.zok, avec le programme Zokrates. Le code ci-dessous suppose que vous avez conservé la taille de carte originale, 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 // Le nombre de mines à l'emplacement (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 }
    Afficher tout
  3. Compilez le code Zokrates et créez la clé de vérification. La clé de vérification doit être créée avec la même entropie utilisée dans le serveur d'origine, dans ce cas une chaîne vide (opens in a new tab).

    1zokrates compile --input dig.zok
    2zokrates setup -e ""
  4. Créez le vérificateur Solidity par vous-même, et vérifiez qu'il est fonctionnellement identique à celui sur la blockchain (le serveur ajoute un commentaire, mais ce n'est pas important).

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

Décisions de conception

Dans toute application suffisamment complexe, il existe des objectifs de conception concurrents qui nécessitent des compromis. Examinons certains des compromis et pourquoi la solution actuelle est préférable à d'autres options.

Pourquoi la preuve à divulgation nulle de connaissance

Pour un démineur, vous n'avez pas vraiment besoin de la preuve à divulgation nulle de connaissance. Le serveur peut toujours conserver la carte, puis simplement la révéler entièrement lorsque la partie est terminée. Ensuite, à la fin de la partie, le contrat intelligent peut calculer le hachage de la carte, vérifier qu'il correspond, et s'il ne correspond pas, pénaliser le serveur ou ignorer complètement la partie.

Je n'ai pas utilisé cette solution plus simple car elle ne fonctionne que pour les jeux courts avec un état final bien défini. Quand un jeu est potentiellement infini (comme dans le cas des mondes autonomes (opens in a new tab)), vous avez besoin d'une solution qui prouve l'état sans le révéler.

En tant que tutoriel, cet article avait besoin d'un jeu court et facile à comprendre, mais cette technique est plus utile pour les jeux plus longs.

Pourquoi Zokrates ?

Zokrates (opens in a new tab) n'est pas la seule bibliothèque de preuve à divulgation nulle de connaissance disponible, mais il est similaire à un langage de programmation normal et impératif (opens in a new tab) et prend en charge les variables booléennes.

Pour votre application, avec des exigences différentes, vous pourriez préférer utiliser Circum (opens in a new tab) ou Cairo (opens in a new tab).

Quand compiler Zokrates

Dans ce programme, nous compilons les programmes Zokrates à chaque démarrage du serveur (opens in a new tab). Il s'agit clairement d'un gaspillage de ressources, mais c'est un tutoriel, optimisé pour la simplicité.

Si j'écrivais une application de niveau production, je vérifierais si j'ai un fichier avec les programmes Zokrates compilés pour cette taille de champ de mines, et si c'est le cas, je l'utiliserais. Il en va de même pour le déploiement d'un contrat de vérificateur en chaîne.

Création des clés de vérificateur et de prouveur

La création de clés (opens in a new tab) est un autre calcul pur qui n'a pas besoin d'être fait plus d'une fois pour une taille de champ de mines donnée. Encore une fois, cela n'est fait qu'une seule fois par souci de simplicité.

De plus, nous pourrions utiliser une cérémonie de configuration (opens in a new tab). L'avantage d'une cérémonie de configuration est qu'il faut soit l'entropie, soit un résultat intermédiaire de chaque participant pour tricher sur la preuve à divulgation nulle de connaissance. Si au moins un participant à la cérémonie est honnête et supprime cette information, les preuves à divulgation nulle de connaissance sont à l'abri de certaines attaques. Cependant, il n'y a aucun mécanisme pour vérifier que l'information a été supprimée de partout. Si les preuves à divulgation nulle de connaissance sont d'une importance capitale, vous voudrez participer à la cérémonie de configuration.

Ici, nous nous appuyons sur les pouvoirs perpétuels de tau (opens in a new tab), qui ont eu des dizaines de participants. C'est probablement assez sûr, et beaucoup plus simple. Nous n'ajoutons pas non plus d'entropie lors de la création des clés, ce qui facilite la vérification de la configuration de preuve à divulgation nulle de connaissance par les utilisateurs.

Où vérifier

Nous pouvons vérifier les preuves à divulgation nulle de connaissance soit en chaîne (ce qui coûte du gaz), soit dans le client (en utilisant verify (opens in a new tab)). J'ai choisi la première option, car cela permet de vérifier le vérificateur une fois pour toutes, puis de faire confiance au fait qu'il ne change pas tant que l'adresse du contrat reste la même. Si la vérification était effectuée sur le client, vous devriez vérifier le code que vous recevez à chaque fois que vous téléchargez le client.

De plus, bien que ce jeu soit solo, beaucoup de jeux sur la blockchain sont multijoueurs. La vérification en chaîne signifie que vous ne vérifiez la preuve à divulgation nulle de connaissance qu'une seule fois. Le faire dans le client exigerait que chaque client vérifie indépendamment.

Aplatir la carte en TypeScript ou en Zokrates ?

En général, lorsque le traitement peut être effectué soit en TypeScript soit en Zokrates, il est préférable de le faire en TypeScript, qui est beaucoup plus rapide et ne nécessite pas de preuves à divulgation nulle de connaissance. C'est la raison, par exemple, pour laquelle nous ne fournissons pas à Zokrates le hachage pour qu'il vérifie qu'il est correct. Le hachage doit être fait à l'intérieur de Zokrates, mais la correspondance entre le hachage retourné et le hachage en chaîne peut se faire en dehors.

Cependant, nous aplatissons toujours la carte dans Zokrates (opens in a new tab), alors que nous aurions pu le faire en TypeScript. La raison est que les autres options sont, à mon avis, pires.

  • Fournir un tableau unidimensionnel de booléens au code Zokrates, et utiliser une expression telle que x*(height+2) +y pour obtenir la carte bidimensionnelle. Cela rendrait le code (opens in a new tab) quelque peu plus compliqué, j'ai donc décidé que le gain de performance ne valait pas la peine pour un tutoriel.

  • Envoyer à Zokrates à la fois le tableau unidimensionnel et le tableau bidimensionnel. Cependant, cette solution ne nous apporte rien. Le code Zokrates devrait vérifier que le tableau unidimensionnel qui lui est fourni est bien la représentation correcte du tableau bidimensionnel. Il n'y aurait donc aucun gain de performance.

  • Aplatir le tableau à deux dimensions dans Zokrates. C'est l'option la plus simple, donc je l'ai choisie.

Où stocker les cartes

Dans cette application, gamesInProgress (opens in a new tab) est simplement une variable en mémoire. Cela signifie que si votre serveur tombe en panne et doit être redémarré, toutes les informations qu'il stockait sont perdues. Non seulement les joueurs sont incapables de continuer leur partie, mais ils ne peuvent même pas en commencer une nouvelle car le composant en chaîne pense qu'ils ont toujours une partie en cours.

C'est clairement une mauvaise conception pour un système de production, dans lequel vous stockeriez ces informations dans une base de données. La seule raison pour laquelle j'ai utilisé une variable ici est que c'est un tutoriel et que la simplicité est la principale considération.

Conclusion : Dans quelles conditions cette technique est-elle appropriée ?

Donc, vous savez maintenant comment écrire un jeu avec un serveur qui stocke un état secret qui n'a pas sa place sur la chaîne. Mais dans quels cas devriez-vous le faire ? Il y a deux considérations principales.

  • Jeu de longue durée : Comme mentionné ci-dessus, dans un jeu court, vous pouvez simplement publier l'état une fois la partie terminée et faire tout vérifier à ce moment-là. Mais ce n'est pas une option lorsque le jeu dure longtemps ou indéfiniment, et que l'état doit rester secret.

  • Une certaine centralisation acceptable : les preuves à divulgation nulle de connaissance peuvent vérifier l'intégrité, qu'une entité ne falsifie pas les résultats. Ce qu'ils ne peuvent pas faire, c'est garantir que l'entité sera toujours disponible et répondra aux messages. Dans les situations où la disponibilité doit également être décentralisée, les preuves à divulgation nulle de connaissance ne sont pas une solution suffisante, et vous avez besoin du calcul multipartite (opens in a new tab).

Voir ici pour plus de mon travail (opens in a new tab).

Remerciements

  • Alvaro Alonso a lu une ébauche de cet article et a clarifié certaines de mes incompréhensions sur Zokrates.

Toute erreur restante est de ma responsabilité.

Dernière mise à jour de la page : 25 février 2026

Ce tutoriel vous a été utile ?