Utilizzare la conoscenza zero per uno stato segreto
Non ci sono segreti sulla blockchain. Tutto ciò che viene pubblicato sulla blockchain è aperto alla lettura di tutti. Questo è necessario, perché la blockchain si basa sul fatto che chiunque possa verificarla. Tuttavia, i giochi spesso si basano su uno stato segreto. Ad esempio, il gioco del campo minato (opens in a new tab) non ha assolutamente senso se puoi semplicemente andare su un block explorer e vedere la mappa.
La soluzione più semplice è utilizzare un componente server per mantenere lo stato segreto. Tuttavia, il motivo per cui utilizziamo la blockchain è prevenire i raggiri da parte dello sviluppatore del gioco. Dobbiamo garantire l'onestà del componente server. Il server può fornire un hash dello stato e utilizzare le prove a conoscenza zero per dimostrare che lo stato utilizzato per calcolare il risultato di una mossa sia quello corretto.
Dopo aver letto questo articolo saprai come creare questo tipo di server che mantiene uno stato segreto, un client per mostrare lo stato e un componente onchain per la comunicazione tra i due. Gli strumenti principali che utilizzeremo saranno:
| Strumento | Scopo | Verificato sulla versione |
|---|---|---|
| Zokrates (opens in a new tab) | Prove a conoscenza zero e la loro verifica | 1.1.9 |
| TypeScript (opens in a new tab) | Linguaggio di programmazione sia per il server che per il client | 5.4.2 |
| Node (opens in a new tab) | Esecuzione del server | 20.18.2 |
| Viem (opens in a new tab) | Comunicazione con la blockchain | 2.9.20 |
| MUD (opens in a new tab) | Gestione dei dati onchain | 2.0.12 |
| React (opens in a new tab) | Interfaccia utente del client | 18.2.0 |
| Vite (opens in a new tab) | Distribuzione del codice client | 4.2.1 |
Esempio di Minesweeper
Minesweeper (opens in a new tab) è un gioco che include una mappa segreta con un campo minato. Il giocatore sceglie di scavare in una posizione specifica. Se quella posizione ha una mina, il gioco finisce. Altrimenti, il giocatore ottiene il numero di mine negli otto quadrati che circondano quella posizione.
Questa applicazione è scritta utilizzando MUD (opens in a new tab), un framework che ci consente di archiviare dati onchain utilizzando un database chiave-valore (opens in a new tab) e di sincronizzare automaticamente quei dati con componenti offchain. Oltre alla sincronizzazione, MUD semplifica la fornitura del controllo degli accessi e consente ad altri utenti di estendere (opens in a new tab) la nostra applicazione senza autorizzazioni.
Eseguire l'esempio di Minesweeper
Per eseguire l'esempio di Minesweeper:
-
Assicurati di avere installato i prerequisiti (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) emprocs(opens in a new tab). -
Clona il repository.
git clone https://github.com/qbzzt/20240901-secret-state.git -
Installa i pacchetti.
cd 20240901-secret-state/ pnpm install npm install -g mprocsSe Foundry è stato installato come parte di
pnpm install, devi riavviare la shell della riga di comando. -
Compila i contratti
cd packages/contracts forge build cd ../.. -
Avvia il programma (inclusa una blockchain anvil (opens in a new tab)) e attendi.
mprocsNota che l'avvio richiede molto tempo. Per vedere i progressi, usa prima la freccia giù per scorrere fino alla scheda contracts per vedere i contratti MUD in fase di distribuzione. Quando ricevi il messaggio Waiting for file changes…, i contratti sono distribuiti e gli ulteriori progressi avverranno nella scheda server. Lì, attendi fino a quando non ricevi il messaggio Verifier address: 0x.....
Se questo passaggio ha esito positivo, vedrai la schermata di
mprocs, con i diversi processi a sinistra e l'output della console per il processo attualmente selezionato a destra.Se c'è un problema con
mprocs, puoi eseguire i quattro processi manualmente, ciascuno nella propria finestra della riga di comando:-
Anvil
cd packages/contracts anvil --base-fee 0 --block-time 2 -
Contracts
cd packages/contracts pnpm mud dev-contracts --rpc http://127.0.0.1:8545 -
Server
cd packages/server pnpm start -
Client
cd packages/client pnpm run dev
-
-
Ora puoi navigare verso il client (opens in a new tab), cliccare su New Game e iniziare a giocare.
Tabelle
Abbiamo bisogno di diverse tabelle (opens in a new tab) onchain.
-
Configuration: Questa tabella è un singleton, non ha chiavi e ha un singolo record. Viene utilizzata per contenere le informazioni di configurazione del gioco:height: L'altezza di un campo minatowidth: La larghezza di un campo minatonumberOfBombs: Il numero di bombe in ogni campo minato
-
VerifierAddress: Anche questa tabella è un singleton. Viene utilizzata per contenere una parte della configurazione, l'indirizzo del contratto del verificatore (verifier). Avremmo potuto inserire queste informazioni nella tabellaConfiguration, ma vengono impostate da un componente diverso, il server, quindi è più semplice inserirle in una tabella separata. -
PlayerGame: La chiave è l'indirizzo del giocatore. I dati sono:gameId: valore a 32 byte che è l'hash della mappa su cui il giocatore sta giocando (l'identificatore del gioco).win: un booleano che indica se il giocatore ha vinto la partita.lose: un booleano che indica se il giocatore ha perso la partita.digNumber: il numero di scavi riusciti nella partita.
-
GamePlayer: Questa tabella contiene la mappatura inversa, dagameIdall'indirizzo del giocatore. -
Map: La chiave è una tupla di tre valori:gameId: valore a 32 byte che è l'hash della mappa su cui il giocatore sta giocando (l'identificatore del gioco).- coordinata
x - coordinata
y
Il valore è un singolo numero. È 255 se è stata rilevata una bomba. Altrimenti, è il numero di bombe attorno a quella posizione più uno. Non possiamo usare semplicemente il numero di bombe, perché per impostazione predefinita tutto lo spazio di archiviazione nell'EVM e tutti i valori delle righe in MUD sono zero. Dobbiamo distinguere tra "il giocatore non ha ancora scavato qui" e "il giocatore ha scavato qui e ha scoperto che ci sono zero bombe in giro".
Inoltre, la comunicazione tra il client e il server avviene tramite il componente onchain. Anche questo è implementato utilizzando le tabelle.
PendingGame: Richieste non servite per avviare una nuova partita.PendingDig: Richieste non servite per scavare in un luogo specifico in una partita specifica. Questa è una tabella offchain (opens in a new tab), il che significa che non viene scritta nello spazio di archiviazione dell'EVM, è leggibile solo offchain utilizzando gli eventi.
Flussi di esecuzione e di dati
Questi flussi coordinano l'esecuzione tra il client, il componente onchain e il server.
Inizializzazione
Quando esegui mprocs, avvengono questi passaggi:
-
mprocs(opens in a new tab) esegue quattro componenti:- Anvil (opens in a new tab), che esegue una blockchain locale
- Contracts (opens in a new tab), che compila (se necessario) e distribuisce i contratti per MUD
- Client (opens in a new tab), che esegue Vite (opens in a new tab) per servire l'interfaccia utente e il codice client ai browser web.
- Server (opens in a new tab), che esegue le azioni del server
-
Il pacchetto
contractsdistribuisce i contratti MUD e poi esegue lo scriptPostDeploy.s.sol(opens in a new tab). Questo script imposta la configurazione. Il codice da GitHub specifica un campo minato 10x5 con otto mine al suo interno (opens in a new tab). -
Il server (opens in a new tab) inizia configurando MUD (opens in a new tab). Tra le altre cose, questo attiva la sincronizzazione dei dati, in modo che una copia delle tabelle rilevanti esista nella memoria del server.
-
Il server iscrive una funzione da eseguire quando la tabella
Configurationcambia (opens in a new tab). Questa funzione (opens in a new tab) viene chiamata dopo chePostDeploy.s.solviene eseguito e modifica la tabella. -
Quando la funzione di inizializzazione del server ha la configurazione, chiama
zkFunctions(opens in a new tab) per inizializzare la parte a conoscenza zero del server. Questo non può accadere finché non otteniamo la configurazione perché le funzioni a conoscenza zero devono avere la larghezza e l'altezza del campo minato come costanti. -
Dopo che la parte a conoscenza zero del server è stata inizializzata, il passaggio successivo è distribuire il contratto di verifica a conoscenza zero sulla blockchain (opens in a new tab) e impostare l'indirizzo del verificatore in MUD.
-
Infine, ci iscriviamo agli aggiornamenti in modo da vedere quando un giocatore richiede di iniziare una nuova partita (opens in a new tab) o di scavare in una partita esistente (opens in a new tab).
Nuova partita
Questo è ciò che accade quando il giocatore richiede una nuova partita.
-
Se non c'è nessuna partita in corso per questo giocatore, o ce n'è una ma con un gameId pari a zero, il client visualizza un pulsante per una nuova partita (opens in a new tab). Quando l'utente preme questo pulsante, React esegue la funzione
newGame(opens in a new tab). -
newGame(opens in a new tab) è una chiamata diSystem. In MUD tutte le chiamate vengono instradate tramite il contrattoWorlde nella maggior parte dei casi si chiama<namespace>__<function name>. In questo caso, la chiamata è aapp__newGame, che MUD instrada poi anewGameinGameSystem(opens in a new tab). -
La funzione onchain verifica che il giocatore non abbia una partita in corso e, se non ce n'è una, aggiunge la richiesta alla tabella
PendingGame(opens in a new tab). -
Il server rileva la modifica in
PendingGameed esegue la funzione iscritta (opens in a new tab). Questa funzione chiamanewGame(opens in a new tab), che a sua volta chiamacreateGame(opens in a new tab). -
La prima cosa che fa
createGameè creare una mappa casuale con il numero appropriato di mine (opens in a new tab). Quindi, chiamamakeMapBorders(opens in a new tab) per creare una mappa con bordi vuoti, che è necessaria per Zokrates. Infine,createGamechiamacalculateMapHash, per ottenere l'hash della mappa, che viene utilizzato come ID della partita. -
La funzione
newGameaggiunge la nuova partita agamesInProgress. -
L'ultima cosa che fa il server è chiamare
app__newGameResponse(opens in a new tab), che è onchain. Questa funzione si trova in unSystemdiverso,ServerSystem(opens in a new tab), per abilitare il controllo degli accessi. Il controllo degli accessi è definito nel file di configurazione di MUD (opens in a new tab),mud.config.ts(opens in a new tab).L'elenco degli accessi consente a un solo indirizzo di chiamare il
System. Questo limita l'accesso alle funzioni del server a un singolo indirizzo, in modo che nessuno possa impersonare il server. -
Il componente onchain aggiorna le tabelle rilevanti:
- Crea la partita in
PlayerGame. - Imposta la mappatura inversa in
GamePlayer. - Rimuove la richiesta da
PendingGame.
- Crea la partita in
-
Il server identifica la modifica in
PendingGame, ma non fa nulla perchéwantsGame(opens in a new tab) è falso. -
Sul client
gameRecord(opens in a new tab) è impostato sulla vocePlayerGameper l'indirizzo del giocatore. QuandoPlayerGamecambia, cambia anchegameRecord. -
Se c'è un valore in
gameRecorde la partita non è stata vinta o persa, il client visualizza la mappa (opens in a new tab).
Scavare
-
Il giocatore clicca sul pulsante della cella della mappa (opens in a new tab), che chiama la funzione
dig(opens in a new tab). Questa funzione chiamadigonchain (opens in a new tab). -
Il componente onchain esegue una serie di controlli di integrità (opens in a new tab) e, se hanno esito positivo, aggiunge la richiesta di scavo a
PendingDig(opens in a new tab). -
Il server rileva la modifica in
PendingDig(opens in a new tab). Se è valida (opens in a new tab), chiama il codice a conoscenza zero (opens in a new tab) (spiegato di seguito) per generare sia il risultato che una prova della sua validità. -
Il server (opens in a new tab) chiama
digResponse(opens in a new tab) onchain. -
digResponsefa due cose. Innanzitutto, controlla la prova a conoscenza zero (opens in a new tab). Quindi, se la prova è corretta, chiamaprocessDigResult(opens in a new tab) per elaborare effettivamente il risultato. -
processDigResultcontrolla se la partita è stata persa (opens in a new tab) o vinta (opens in a new tab) e aggiornaMap, la mappa onchain (opens in a new tab). -
Il client rileva automaticamente gli aggiornamenti e aggiorna la mappa visualizzata al giocatore (opens in a new tab) e, se applicabile, comunica al giocatore se si tratta di una vittoria o di una sconfitta.
Usare Zokrates
Nei flussi spiegati sopra abbiamo saltato le parti a conoscenza zero, trattandole come una scatola nera. Ora apriamola e vediamo come è scritto quel codice.
Hashing della mappa
Possiamo usare questo codice JavaScript (opens in a new tab) per implementare Poseidon (opens in a new tab), la funzione di hash di Zokrates che utilizziamo. Tuttavia, sebbene questo sarebbe più veloce, sarebbe anche più complicato rispetto al semplice utilizzo della funzione di hash di Zokrates per farlo. Questo è un tutorial, e quindi il codice è ottimizzato per la semplicità, non per le prestazioni. Pertanto, abbiamo bisogno di due diversi programmi Zokrates, uno per calcolare semplicemente l'hash di una mappa (hash) e uno per creare effettivamente una prova a conoscenza zero del risultato dello scavo in una posizione sulla mappa (dig).
La funzione di hash
Questa è la funzione che calcola l'hash di una mappa. Esamineremo questo codice riga per riga.
import "hashes/poseidon/poseidon.zok" as poseidon;
import "utils/pack/bool/pack128.zok" as pack128;
Queste due righe importano due funzioni dalla libreria standard di Zokrates (opens in a new tab). La prima funzione (opens in a new tab) è un hash Poseidon (opens in a new tab). Prende un array di elementi field (opens in a new tab) e restituisce un field.
L'elemento field in Zokrates è in genere lungo meno di 256 bit, ma non di molto. Per semplificare il codice, limitiamo la mappa a un massimo di 512 bit e facciamo l'hashing di un array di quattro field, e in ogni field usiamo solo 128 bit. La funzione pack128 (opens in a new tab) trasforma un array di 128 bit in un field a questo scopo.
def hashMap(bool[${width+2}][${height+2}] map) -> field {
Questa riga inizia la definizione di una funzione. hashMap ottiene un singolo parametro chiamato map, un array bool(eano) bidimensionale. La dimensione della mappa è width+2 per height+2 per motivi che sono spiegati di seguito.
Possiamo usare ${width+2} e ${height+2} perché i programmi Zokrates sono memorizzati in questa applicazione come template string (opens in a new tab). Il codice tra ${ e } viene valutato da JavaScript, e in questo modo il programma può essere utilizzato per diverse dimensioni della mappa. Il parametro della mappa ha un bordo largo una posizione tutto intorno senza alcuna bomba, che è il motivo per cui dobbiamo aggiungere due alla larghezza e all'altezza.
Il valore di ritorno è un field che contiene l'hash.
bool[512] mut map1d = [false; 512];
La mappa è bidimensionale. Tuttavia, la funzione pack128 non funziona con array bidimensionali. Quindi prima appiattiamo la mappa in un array di 512 byte, usando map1d. Per impostazione predefinita le variabili Zokrates sono costanti, ma dobbiamo assegnare valori a questo array in un ciclo, quindi lo definiamo come mut (opens in a new tab).
Dobbiamo inizializzare l'array perché Zokrates non ha undefined. L'espressione [false; 512] significa un array di 512 valori false (opens in a new tab).
u32 mut counter = 0;
Abbiamo anche bisogno di un contatore per distinguere tra i bit che abbiamo già riempito in map1d e quelli che non abbiamo riempito.
for u32 x in 0..${width+2} {
Ecco come si dichiara un ciclo for (opens in a new tab) in Zokrates. Un ciclo for in Zokrates deve avere limiti fissi, perché sebbene sembri essere un ciclo, il compilatore in realtà lo "srotola" (unroll). L'espressione ${width+2} è una costante a tempo di compilazione perché width è impostato dal codice TypeScript prima che chiami il compilatore.
for u32 y in 0..${height+2} {
map1d[counter] = map[x][y];
counter = counter+1;
}
}
Per ogni posizione nella mappa, inserisci quel valore nell'array map1d e incrementa il contatore.
field[4] hashMe = [
pack128(map1d[0..128]),
pack128(map1d[128..256]),
pack128(map1d[256..384]),
pack128(map1d[384..512])
];
Il pack128 serve a creare un array di quattro valori field da map1d. In Zokrates array[a..b] indica la porzione dell'array che inizia a a e finisce a b-1.
return poseidon(hashMe);
}
Usa poseidon per convertire questo array in un hash.
Il programma di hash
Il server deve chiamare hashMap direttamente per creare gli identificatori di gioco. Tuttavia, Zokrates può chiamare solo la funzione main su un programma per iniziare, quindi creiamo un programma con un main che chiama la funzione di hash.
${hashFragment}
def main(bool[${width+2}][${height+2}] map) -> field {
return hashMap(map);
}
Il programma di scavo
Questo è il cuore della parte a conoscenza zero dell'applicazione, dove produciamo le prove che vengono utilizzate per verificare i risultati degli scavi.
${hashFragment}
// Il numero di mine nella posizione (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 };
}
Perché il bordo della mappa
Le prove a conoscenza zero usano circuiti aritmetici (opens in a new tab), che non hanno un equivalente semplice per un'istruzione if. Invece, usano l'equivalente dell'operatore condizionale (opens in a new tab). Se a può essere zero o uno, puoi calcolare if a { b } else { c } come ab+(1-a)c.
Per questo motivo, un'istruzione if in Zokrates valuta sempre entrambi i rami. Ad esempio, se hai questo codice:
bool[5] arr = [false; 5];
u32 index=10;
return if index>4 { 0 } else { arr[index] }
Andrà in errore, perché deve calcolare arr[10], anche se quel valore verrà successivamente moltiplicato per zero.
Questo è il motivo per cui abbiamo bisogno di un bordo largo una posizione tutto intorno alla mappa. Dobbiamo calcolare il numero totale di mine attorno a una posizione, e questo significa che dobbiamo vedere la posizione una riga sopra e sotto, a sinistra e a destra, della posizione in cui stiamo scavando. Il che significa che quelle posizioni devono esistere nell'array della mappa fornito a Zokrates.
def main(private bool[${width+2}][${height+2}] map, u32 x, u32 y) -> (field, u8) {
Per impostazione predefinita, le prove di Zokrates includono i loro input. Non serve a nulla sapere che ci sono cinque mine attorno a un punto a meno che tu non sappia effettivamente quale punto sia (e non puoi semplicemente abbinarlo alla tua richiesta, perché allora il prover potrebbe usare valori diversi e non dirtelo). Tuttavia, dobbiamo mantenere segreta la mappa, pur fornendola a Zokrates. La soluzione è usare un parametro private, uno che non viene rivelato dalla prova.
Questo apre un'altra possibilità di abuso. Il prover potrebbe usare le coordinate corrette, ma creare una mappa con un numero qualsiasi di mine attorno alla posizione, e possibilmente nella posizione stessa. Per prevenire questo abuso, facciamo in modo che la prova a conoscenza zero includa l'hash della mappa, che è l'identificatore del gioco.
return (hashMap(map),
Il valore di ritorno qui è una tupla che include l'array dell'hash della mappa e il risultato dello scavo.
if map2mineCount(map, x, y) > 0 { 0xFF } else {
Usiamo 255 come valore speciale nel caso in cui la posizione stessa abbia una bomba.
map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
}
);
}
Se il giocatore non ha colpito una mina, somma i conteggi delle mine per l'area attorno alla posizione e restituisci quello.
Usare Zokrates da TypeScript
Zokrates ha un'interfaccia a riga di comando, ma in questo programma lo usiamo nel codice TypeScript (opens in a new tab).
La libreria che contiene le definizioni di Zokrates si chiama zero-knowledge.ts (opens in a new tab).
import { initialize as zokratesInitialize } from "zokrates-js"
Importa i binding JavaScript di Zokrates (opens in a new tab). Abbiamo bisogno solo della funzione initialize (opens in a new tab) perché restituisce una promise che si risolve in tutte le definizioni di Zokrates.
export const zkFunctions = async (width: number, height: number) : Promise<any> => {
Similmente a Zokrates stesso, esportiamo anche noi una sola funzione, che è anch'essa asincrona (opens in a new tab). Quando alla fine ritorna, fornisce diverse funzioni come vedremo di seguito.
const zokrates = await zokratesInitialize()
Inizializza Zokrates, ottieni tutto ciò di cui abbiamo bisogno dalla libreria.
const hashFragment = `
import "utils/pack/bool/pack128.zok" as pack128;
import "hashes/poseidon/poseidon.zok" as poseidon;
.
.
.
}
`
const hashProgram = `
${hashFragment}
.
.
.
`
const digProgram = `
${hashFragment}
.
.
.
`
Successivamente abbiamo la funzione di hash e i due programmi Zokrates che abbiamo visto sopra.
const digCompiled = zokrates.compile(digProgram)
const hashCompiled = zokrates.compile(hashProgram)
Qui compiliamo quei programmi.
// Crea le chiavi per la verifica a conoscenza zero.
// In un sistema di produzione vorresti usare una cerimonia di setup.
// (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
Su un sistema di produzione potremmo usare una cerimonia di configurazione (opens in a new tab) più complicata, ma questa è sufficiente per una dimostrazione. Non è un problema che gli utenti possano conoscere la chiave del prover: non possono comunque usarla per provare cose a meno che non siano vere. Poiché specifichiamo l'entropia (il secondo parametro, ""), i risultati saranno sempre gli stessi.
Nota: La compilazione dei programmi Zokrates e la creazione delle chiavi sono processi lenti. Non c'è bisogno di ripeterli ogni volta, solo quando cambia la dimensione della mappa. Su un sistema di produzione li faresti una volta, per poi memorizzare l'output. L'unico motivo per cui non lo sto facendo qui è per amore di semplicità.
calculateMapHash
const calculateMapHash = function (hashMe: boolean[][]): string {
return (
"0x" +
BigInt(zokrates.computeWitness(hashCompiled, [hashMe]).output.slice(1, -1))
.toString(16)
.padStart(64, "0")
)
}
La funzione computeWitness (opens in a new tab) esegue effettivamente il programma Zokrates. Restituisce una struttura con due campi: output, che è l'output del programma come stringa JSON, e witness, che sono le informazioni necessarie per creare la prova a conoscenza zero del risultato. Qui abbiamo solo bisogno dell'output.
L'output è una stringa della forma "31337", un numero decimale racchiuso tra virgolette. Ma l'output di cui abbiamo bisogno per viem è un numero esadecimale della forma 0x60A7. Quindi usiamo .slice(1,-1) per rimuovere le virgolette e poi BigInt per convertire la stringa rimanente, che è un numero decimale, in un BigInt (opens in a new tab). .toString(16) converte questo BigInt in una stringa esadecimale, e "0x"+ aggiunge il marcatore per i numeri esadecimali.
// Scava e restituisci una prova a conoscenza zero del risultato
// (codice lato server)
La prova a conoscenza zero include gli input pubblici (x e y) e i risultati (hash della mappa e numero di bombe).
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")
È un problema controllare se un indice è fuori dai limiti in Zokrates, quindi lo facciamo qui.
const runResults = zokrates.computeWitness(digCompiled, [map, `${x}`, `${y}`])
Esegui il programma di scavo.
const proof = zokrates.generateProof(
digCompiled.program,
runResults.witness,
proverKey)
return proof
}
Usa generateProof (opens in a new tab) e restituisci la prova.
const solidityVerifier = `
// Map size: ${width} x ${height}
\n${zokrates.exportSolidityVerifier(verifierKey)}
`
Un verificatore Solidity, uno smart contract che possiamo distribuire sulla blockchain e usare per verificare le prove generate da digCompiled.program.
return {
zkDig,
calculateMapHash,
solidityVerifier,
}
}
Infine, restituisci tutto ciò di cui altro codice potrebbe aver bisogno.
Test di sicurezza
I test di sicurezza sono importanti perché un bug di funzionalità prima o poi si rivelerà. Ma se l'applicazione è insicura, è probabile che ciò rimanga nascosto per molto tempo prima di essere rivelato da qualcuno che imbroglia e se la cava con risorse che appartengono ad altri.
Permessi
C'è un'entità privilegiata in questo gioco, il server. È l'unico utente autorizzato a chiamare le funzioni in ServerSystem (opens in a new tab). Possiamo usare cast (opens in a new tab) per verificare che le chiamate alle funzioni autorizzate siano consentite solo come account del server.
La chiave privata del server si trova in setupNetwork.ts (opens in a new tab).
-
Sul computer che esegue
anvil(la blockchain), imposta queste variabili d'ambiente.WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d -
Usa
castper tentare di impostare l'indirizzo del verificatore come un indirizzo non autorizzato.cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $UNAUTHORIZED_KEYNon solo
castsegnala un fallimento, ma puoi aprire i MUD Dev Tools nel gioco sul browser, cliccare su Tables e selezionare app__VerifierAddress. Nota che l'indirizzo non è zero. -
Imposta l'indirizzo del verificatore come l'indirizzo del server.
cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $AUTHORIZED_KEYL'indirizzo in app__VerifiedAddress dovrebbe ora essere zero.
Tutte le funzioni MUD nello stesso System passano attraverso lo stesso controllo degli accessi, quindi considero questo test sufficiente. Se non sei d'accordo, puoi controllare le altre funzioni in ServerSystem (opens in a new tab).
Abusi a conoscenza zero
La matematica per verificare Zokrates va oltre lo scopo di questo tutorial (e delle mie capacità). Tuttavia, possiamo eseguire vari controlli sul codice a conoscenza zero per verificare che, se non viene eseguito correttamente, fallisca. Tutti questi test richiederanno di modificare zero-knowledge.ts (opens in a new tab) e riavviare l'intera applicazione. Non è sufficiente riavviare il processo del server, perché mette l'applicazione in uno stato impossibile (il giocatore ha una partita in corso, ma il gioco non è più disponibile per il server).
Risposta sbagliata
La possibilità più semplice è fornire la risposta sbagliata nella prova a conoscenza zero. Per farlo, andiamo all'interno di zkDig e modifichiamo la riga 91 (opens in a new tab):
proof.inputs[3] = "0x" + "1".padStart(64, "0")
Questo significa che dichiareremo sempre che c'è una bomba, indipendentemente dalla risposta corretta. Prova a giocare con questa versione e vedrai nella scheda server della schermata pnpm dev questo errore:
cause: {
code: 3,
message: 'execution reverted: revert: Zero knowledge verification fail',
data: '0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000
000000000000000000000000000000000000000000000000205a65726f206b6e6f776c6564676520766572696669636174696f6
e206661696c'
},
Quindi questo tipo di trucco fallisce.
Prova sbagliata
Cosa succede se forniamo le informazioni corrette, ma abbiamo semplicemente i dati della prova sbagliati? Ora, sostituisci la riga 91 con:
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")],
}
Fallisce comunque, ma ora fallisce senza un motivo perché accade durante la chiamata del verificatore.
Come può un utente verificare il codice zero trust?
Gli smart contract sono relativamente facili da verificare. In genere, lo sviluppatore pubblica il codice sorgente su un block explorer e il block explorer verifica che il codice sorgente venga compilato nel codice della transazione di distribuzione del contratto. Nel caso dei System MUD questo è leggermente più complicato (opens in a new tab), ma non di molto.
Questo è più difficile con la conoscenza zero. Il verificatore include alcune costanti ed esegue alcuni calcoli su di esse. Questo non ti dice cosa viene provato.
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)]);
La soluzione, almeno finché i block explorer non decideranno di aggiungere la verifica di Zokrates alle loro interfacce utente, è che gli sviluppatori dell'applicazione rendano disponibili i programmi Zokrates e che almeno alcuni utenti li compilino da soli con la chiave di verifica appropriata.
Per farlo:
-
Crea un file,
dig.zok, con il programma Zokrates. Il codice sottostante presuppone che tu abbia mantenuto la dimensione originale della mappa, 10x5.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); } // Il numero di mine nella posizione (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) } ); } -
Compila il codice Zokrates e crea la chiave di verifica. La chiave di verifica deve essere creata con la stessa entropia utilizzata nel server originale, in questo caso una stringa vuota (opens in a new tab).
zokrates compile --input dig.zok zokrates setup -e "" -
Crea il verificatore Solidity per conto tuo e verifica che sia funzionalmente identico a quello sulla blockchain (il server aggiunge un commento, ma non è importante).
zokrates export-verifier diff verifier.sol ~/20240901-secret-state/packages/contracts/src/verifier.sol
Decisioni di progettazione
In qualsiasi applicazione sufficientemente complessa ci sono obiettivi di progettazione in competizione che richiedono dei compromessi. Diamo un'occhiata ad alcuni di questi compromessi e al motivo per cui la soluzione attuale è preferibile ad altre opzioni.
Perché a conoscenza zero
Per il campo minato non hai davvero bisogno della conoscenza zero. Il server può sempre conservare la mappa e poi rivelarla tutta quando il gioco è finito. Quindi, alla fine del gioco, lo smart contract può calcolare l'hash della mappa, verificare che corrisponda e, in caso contrario, penalizzare il server o ignorare completamente la partita.
Non ho usato questa soluzione più semplice perché funziona solo per giochi brevi con uno stato finale ben definito. Quando un gioco è potenzialmente infinito (come nel caso dei mondi autonomi (opens in a new tab)), hai bisogno di una soluzione che dimostri lo stato senza rivelarlo.
Essendo un tutorial, questo articolo aveva bisogno di un gioco breve e facile da capire, ma questa tecnica è più utile per i giochi più lunghi.
Perché Zokrates?
Zokrates (opens in a new tab) non è l'unica libreria a conoscenza zero disponibile, ma è simile a un normale linguaggio di programmazione imperativo (opens in a new tab) e supporta le variabili booleane.
Per la tua applicazione, con requisiti diversi, potresti preferire usare Circum (opens in a new tab) o Cairo (opens in a new tab).
Quando compilare Zokrates
In questo programma compiliamo i programmi Zokrates ogni volta che il server si avvia (opens in a new tab). Questo è chiaramente uno spreco di risorse, ma questo è un tutorial, ottimizzato per la semplicità.
Se stessi scrivendo un'applicazione a livello di produzione, controllerei se ho un file con i programmi Zokrates compilati per questa dimensione del campo minato e, in tal caso, userei quello. Lo stesso vale per distribuire un contratto verificatore onchain.
Creazione delle chiavi del verificatore e del prover
La creazione della chiave (opens in a new tab) è un altro calcolo puro che non deve essere eseguito più di una volta per una data dimensione del campo minato. Ancora una volta, viene fatto solo una volta per motivi di semplicità.
Inoltre, potremmo usare una cerimonia di configurazione (opens in a new tab). Il vantaggio di una cerimonia di configurazione è che hai bisogno dell'entropia o di qualche risultato intermedio da ogni partecipante per imbrogliare sulla prova a conoscenza zero. Se almeno un partecipante alla cerimonia è onesto ed elimina tali informazioni, le prove a conoscenza zero sono al sicuro da determinati attacchi. Tuttavia, non esiste alcun meccanismo per verificare che le informazioni siano state eliminate ovunque. Se le prove a conoscenza zero sono di fondamentale importanza, vorrai partecipare alla cerimonia di configurazione.
Qui ci affidiamo alle potenze perpetue di tau (opens in a new tab), che hanno avuto dozzine di partecipanti. È probabilmente abbastanza sicuro e molto più semplice. Inoltre, non aggiungiamo entropia durante la creazione della chiave, il che rende più facile per gli utenti verificare la configurazione a conoscenza zero.
Dove verificare
Possiamo verificare le prove a conoscenza zero onchain (il che costa gas) o nel client (usando verify (opens in a new tab)). Ho scelto la prima opzione, perché questo ti permette di verificare il verificatore una volta e poi fidarti che non cambi finché l'indirizzo del contratto per esso rimane lo stesso. Se la verifica venisse eseguita sul client, dovresti verificare il codice che ricevi ogni volta che scarichi il client.
Inoltre, sebbene questo gioco sia per giocatore singolo, molti giochi blockchain sono multigiocatore. La verifica onchain significa che verifichi la prova a conoscenza zero solo una volta. Farlo nel client richiederebbe a ciascun client di verificare in modo indipendente.
Appiattire la mappa in TypeScript o Zokrates?
In generale, quando l'elaborazione può essere eseguita in TypeScript o Zokrates, è meglio farla in TypeScript, che è molto più veloce e non richiede prove a conoscenza zero. Questo è il motivo, ad esempio, per cui non forniamo a Zokrates l'hash e gli facciamo verificare che sia corretto. L'hashing deve essere eseguito all'interno di Zokrates, ma la corrispondenza tra l'hash restituito e l'hash onchain può avvenire all'esterno.
Tuttavia, continuiamo ad appiattire la mappa in Zokrates (opens in a new tab), mentre avremmo potuto farlo in TypeScript. Il motivo è che le altre opzioni sono, a mio parere, peggiori.
-
Fornire un array unidimensionale di booleani al codice Zokrates e usare un'espressione come
x*(height+2) +yper ottenere la mappa bidimensionale. Questo renderebbe il codice (opens in a new tab) un po' più complicato, quindi ho deciso che il guadagno in termini di prestazioni non ne vale la pena per un tutorial. -
Inviare a Zokrates sia l'array unidimensionale che l'array bidimensionale. Tuttavia, questa soluzione non ci fa guadagnare nulla. Il codice Zokrates dovrebbe verificare che l'array unidimensionale fornito sia davvero la rappresentazione corretta dell'array bidimensionale. Quindi non ci sarebbe alcun guadagno in termini di prestazioni.
-
Appiattire l'array bidimensionale in Zokrates. Questa è l'opzione più semplice, quindi l'ho scelta.
Dove memorizzare le mappe
In questa applicazione gamesInProgress (opens in a new tab) è semplicemente una variabile in memoria. Ciò significa che se il tuo server si arresta in modo anomalo e deve essere riavviato, tutte le informazioni memorizzate vanno perse. Non solo i giocatori non sono in grado di continuare la loro partita, ma non possono nemmeno iniziarne una nuova perché il componente onchain pensa che abbiano ancora una partita in corso.
Questa è chiaramente una cattiva progettazione per un sistema di produzione, in cui memorizzeresti queste informazioni in un database. L'unico motivo per cui ho usato una variabile qui è perché questo è un tutorial e la semplicità è la considerazione principale.
Conclusione: in quali condizioni questa è la tecnica appropriata?
Quindi, ora sai come scrivere un gioco con un server che memorizza uno stato segreto che non appartiene onchain. Ma in quali casi dovresti farlo? Ci sono due considerazioni principali.
-
Gioco di lunga durata: Come menzionato sopra, in un gioco breve puoi semplicemente pubblicare lo stato una volta terminato il gioco e far verificare tutto in quel momento. Ma questa non è un'opzione quando il gioco richiede un tempo lungo o indefinito e lo stato deve rimanere segreto.
-
Una certa centralizzazione è accettabile: le prove a conoscenza zero possono verificare l'integrità, ovvero che un'entità non stia falsificando i risultati. Ciò che non possono fare è garantire che l'entità sarà ancora disponibile e risponderà ai messaggi. Nelle situazioni in cui anche la disponibilità deve essere decentralizzata, le prove a conoscenza zero non sono una soluzione sufficiente ed è necessario il calcolo multiparte (opens in a new tab).
Vedi qui per altri miei lavori (opens in a new tab).
Ringraziamenti
- Alvaro Alonso ha letto una bozza di questo articolo e ha chiarito alcuni dei miei malintesi su Zokrates.
Eventuali errori rimanenti sono di mia responsabilità.
