Přejít na hlavní obsah

Využití nulového vědomí pro tajný stav

server
offchain
centralizované
nulové vědomí
zokrates
mud
soukromí
Pokročilý
Ori Pomerantz
15. března 2025
24 minut čtení

Na blockchainu neexistují žádná tajemství. Vše, co je zveřejněno na blockchainu, si může kdokoli přečíst. To je nezbytné, protože blockchain je založen na tom, že jej může kdokoli ověřit. Hry však často spoléhají na tajný stav. Například hra Hledání min (opens in a new tab) nedává absolutně žádný smysl, pokud můžete jednoduše jít do prohlížeče bloků a podívat se na mapu.

Nejjednodušším řešením je použít serverovou komponentu k uchování tajného stavu. Důvodem, proč používáme blockchain, je však zabránit podvádění ze strany vývojáře hry. Musíme zajistit poctivost serverové komponenty. Server může poskytnout hash stavu a použít důkazy s nulovou znalostí k prokázání, že stav použitý k výpočtu výsledku tahu je ten správný.

Po přečtení tohoto článku budete vědět, jak vytvořit tento druh serveru uchovávajícího tajný stav, klienta pro zobrazení stavu a onchain komponentu pro komunikaci mezi nimi. Hlavní nástroje, které použijeme, budou:

NástrojÚčelOvěřeno na verzi
Zokrates (opens in a new tab)Důkazy s nulovou znalostí a jejich ověřování1.1.9
TypeScript (opens in a new tab)Programovací jazyk pro server i klienta5.4.2
Node (opens in a new tab)Spuštění serveru20.18.2
Viem (opens in a new tab)Komunikace s blockchainem2.9.20
MUD (opens in a new tab)Správa onchain dat2.0.12
React (opens in a new tab)Uživatelské rozhraní klienta18.2.0
Vite (opens in a new tab)Poskytování kódu klienta4.2.1

Příklad hry Hledání min

Hledání min (opens in a new tab) je hra, která obsahuje tajnou mapu s minovým polem. Hráč si vybere, že bude kopat na určitém místě. Pokud je na tomto místě mina, hra končí. V opačném případě hráč získá počet min v osmi polích obklopujících toto místo.

Tato aplikace je napsána pomocí MUD (opens in a new tab), frameworku, který nám umožňuje ukládat data onchain pomocí databáze klíč-hodnota (opens in a new tab) a automaticky tato data synchronizovat s offchain komponentami. Kromě synchronizace MUD usnadňuje poskytování řízení přístupu a umožňuje ostatním uživatelům rozšířit (opens in a new tab) naši aplikaci bez oprávnění.

Spuštění příkladu hry Hledání min

Pro spuštění příkladu hry Hledání min:

  1. Ujistěte se, že máte nainstalované předpoklady (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) a mprocs (opens in a new tab).

  2. Naklonujte repozitář.

    git clone https://github.com/qbzzt/20240901-secret-state.git
    
  3. Nainstalujte balíčky.

    cd 20240901-secret-state/
    pnpm install
    npm install -g mprocs
    

    Pokud bylo Foundry nainstalováno jako součást pnpm install, musíte restartovat příkazový řádek.

  4. Zkompilujte kontrakty

    cd packages/contracts
    forge build
    cd ../..
    
  5. Spusťte program (včetně blockchainu anvil (opens in a new tab)) a počkejte.

    mprocs
    

    Vezměte na vědomí, že spuštění trvá dlouho. Chcete-li vidět průběh, nejprve pomocí šipky dolů přejděte na kartu contracts, kde uvidíte, jak se nasazují MUD kontrakty. Když se zobrazí zpráva Waiting for file changes…, kontrakty jsou nasazeny a další průběh se bude odehrávat na kartě server. Tam počkejte, dokud se nezobrazí zpráva Verifier address: 0x.....

    Pokud je tento krok úspěšný, uvidíte obrazovku mprocs s různými procesy vlevo a výstupem konzole pro aktuálně vybraný proces vpravo.

    The mprocs screen

    Pokud se vyskytne problém s mprocs, můžete spustit čtyři procesy ručně, každý ve vlastním okně příkazového řádku:

    • 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
      
  6. Nyní můžete přejít na klienta (opens in a new tab), kliknout na New Game a začít hrát.

Tabulky

Potřebujeme několik tabulek (opens in a new tab) onchain.

  • Configuration: Tato tabulka je singleton, nemá žádný klíč a obsahuje jediný záznam. Používá se k uchování informací o konfiguraci hry:

    • height: Výška minového pole
    • width: Šířka minového pole
    • numberOfBombs: Počet bomb v každém minovém poli
  • VerifierAddress: Tato tabulka je také singleton. Používá se k uchování jedné části konfigurace, adresy kontraktu ověřovatele (verifier). Tyto informace jsme mohli vložit do tabulky Configuration, ale nastavuje je jiná komponenta, server, takže je jednodušší je umístit do samostatné tabulky.

  • PlayerGame: Klíčem je adresa hráče. Data jsou:

    • gameId: 32bajtová hodnota, která je hashem mapy, na které hráč hraje (identifikátor hry).
    • win: boolean určující, zda hráč vyhrál hru.
    • lose: boolean určující, zda hráč prohrál hru.
    • digNumber: počet úspěšných výkopů ve hře.
  • GamePlayer: Tato tabulka obsahuje reverzní mapování, z gameId na adresu hráče.

  • Map: Klíčem je n-tice (tuple) tří hodnot:

    • gameId: 32bajtová hodnota, která je hashem mapy, na které hráč hraje (identifikátor hry).
    • souřadnice x
    • souřadnice y

    Hodnotou je jediné číslo. Je to 255, pokud byla detekována bomba. V opačném případě je to počet bomb v okolí tohoto místa plus jedna. Nemůžeme použít pouze počet bomb, protože ve výchozím nastavení je veškeré úložiště v EVM a všechny hodnoty řádků v MUD nulové. Potřebujeme rozlišit mezi „hráč zde ještě nekopal“ a „hráč zde kopal a zjistil, že v okolí je nula bomb“.

Kromě toho probíhá komunikace mezi klientem a serverem prostřednictvím onchain komponenty. To je také implementováno pomocí tabulek.

  • PendingGame: Nevyřízené požadavky na spuštění nové hry.
  • PendingDig: Nevyřízené požadavky na kopání na konkrétním místě v konkrétní hře. Jedná se o offchain tabulku (opens in a new tab), což znamená, že se nezapisuje do úložiště EVM, je čitelná pouze offchain pomocí událostí.

Toky provádění a dat

Tyto toky koordinují provádění mezi klientem, onchain komponentou a serverem.

Inicializace

Když spustíte mprocs, stanou se tyto kroky:

  1. mprocs (opens in a new tab) spustí čtyři komponenty:

  2. Balíček contracts nasadí MUD kontrakty a poté spustí skript PostDeploy.s.sol (opens in a new tab). Tento skript nastaví konfiguraci. Kód z GitHubu specifikuje minové pole 10x5 s osmi minami (opens in a new tab).

  3. Server (opens in a new tab) začíná nastavením MUD (opens in a new tab). Mimo jiné to aktivuje synchronizaci dat, takže v paměti serveru existuje kopie příslušných tabulek.

  4. Server přihlásí k odběru funkci, která se má provést, když se změní tabulka Configuration (opens in a new tab). Tato funkce (opens in a new tab) je volána po provedení PostDeploy.s.sol a úpravě tabulky.

  5. Když má inicializační funkce serveru konfiguraci, zavolá zkFunctions (opens in a new tab) k inicializaci části serveru s nulovým vědomím. To se nemůže stát, dokud nezískáme konfiguraci, protože funkce s nulovým vědomím musí mít šířku a výšku minového pole jako konstanty.

  6. Po inicializaci části serveru s nulovým vědomím je dalším krokem nasazení ověřovacího kontraktu s nulovým vědomím na blockchain (opens in a new tab) a nastavení adresy ověřovatele v MUD.

  7. Nakonec se přihlásíme k odběru aktualizací, abychom viděli, když hráč požádá buď o spuštění nové hry (opens in a new tab), nebo o kopání ve stávající hře (opens in a new tab).

Nová hra

Toto se stane, když hráč požádá o novou hru.

  1. Pokud pro tohoto hráče neprobíhá žádná hra, nebo probíhá, ale s gameId nula, klient zobrazí tlačítko nové hry (opens in a new tab). Když uživatel stiskne toto tlačítko, React spustí funkci newGame (opens in a new tab).

  2. newGame (opens in a new tab) je volání System. V MUD jsou všechna volání směrována přes kontrakt World a ve většině případů voláte <namespace>__<function name>. V tomto případě je volání na app__newGame, které MUD následně nasměruje na newGame v GameSystem (opens in a new tab).

  3. Onchain funkce zkontroluje, zda hráč nemá rozehranou hru, a pokud ne, přidá požadavek do tabulky PendingGame (opens in a new tab).

  4. Server detekuje změnu v PendingGame a spustí přihlášenou funkci (opens in a new tab). Tato funkce zavolá newGame (opens in a new tab), která následně zavolá createGame (opens in a new tab).

  5. První věc, kterou createGame udělá, je vytvoření náhodné mapy s příslušným počtem min (opens in a new tab). Poté zavolá makeMapBorders (opens in a new tab) k vytvoření mapy s prázdnými okraji, což je nezbytné pro Zokrates. Nakonec createGame zavolá calculateMapHash, aby získal hash mapy, který se používá jako ID hry.

  6. Funkce newGame přidá novou hru do gamesInProgress.

  7. Poslední věc, kterou server udělá, je volání app__newGameResponse (opens in a new tab), které je onchain. Tato funkce je v jiném System, ServerSystem (opens in a new tab), aby bylo umožněno řízení přístupu. Řízení přístupu je definováno v konfiguračním souboru MUD (opens in a new tab), mud.config.ts (opens in a new tab).

    Seznam přístupů umožňuje volat System pouze jediné adrese. To omezuje přístup k funkcím serveru na jedinou adresu, takže se nikdo nemůže vydávat za server.

  8. Onchain komponenta aktualizuje příslušné tabulky:

    • Vytvoří hru v PlayerGame.
    • Nastaví reverzní mapování v GamePlayer.
    • Odstraní požadavek z PendingGame.
  9. Server identifikuje změnu v PendingGame, ale nic neudělá, protože wantsGame (opens in a new tab) je nepravda (false).

  10. Na klientovi je gameRecord (opens in a new tab) nastaveno na záznam PlayerGame pro adresu hráče. Když se změní PlayerGame, změní se i gameRecord.

  11. Pokud je v gameRecord hodnota a hra nebyla vyhrána ani prohrána, klient zobrazí mapu (opens in a new tab).

Kopání

  1. Hráč klikne na tlačítko buňky mapy (opens in a new tab), což zavolá funkci dig (opens in a new tab). Tato funkce zavolá dig onchain (opens in a new tab).

  2. Onchain komponenta provede řadu kontrol správnosti (opens in a new tab) a v případě úspěchu přidá požadavek na kopání do PendingDig (opens in a new tab).

  3. Server detekuje změnu v PendingDig (opens in a new tab). Pokud je platná (opens in a new tab), zavolá kód s nulovým vědomím (opens in a new tab) (vysvětleno níže), aby vygeneroval jak výsledek, tak důkaz, že je platný.

  4. Server (opens in a new tab) zavolá digResponse (opens in a new tab) onchain.

  5. digResponse udělá dvě věci. Nejprve zkontroluje důkaz s nulovou znalostí (opens in a new tab). Poté, pokud je důkaz v pořádku, zavolá processDigResult (opens in a new tab), aby skutečně zpracoval výsledek.

  6. processDigResult zkontroluje, zda byla hra prohrána (opens in a new tab) nebo vyhrána (opens in a new tab), a aktualizuje Map, onchain mapu (opens in a new tab).

  7. Klient automaticky zachytí aktualizace a aktualizuje mapu zobrazenou hráči (opens in a new tab), a případně hráči sdělí, zda vyhrál nebo prohrál.

Použití Zokrates

V postupech vysvětlených výše jsme přeskočili části s nulovým vědomím a přistupovali k nim jako k černé skříňce. Nyní ji pojďme otevřít a podívat se, jak je tento kód napsán.

Hashování mapy

Můžeme použít tento kód v JavaScriptu (opens in a new tab) k implementaci Poseidonu (opens in a new tab), což je hashovací funkce Zokrates, kterou používáme. Ačkoli by to však bylo rychlejší, bylo by to také složitější než k tomu jednoduše použít hashovací funkci Zokrates. Toto je tutoriál, a proto je kód optimalizován pro jednoduchost, nikoli pro výkon. Proto potřebujeme dva různé programy Zokrates, jeden pouze pro výpočet hashe mapy (hash) a druhý pro samotné vytvoření důkazu s nulovou znalostí o výsledku kopání na určitém místě na mapě (dig).

Hashovací funkce

Toto je funkce, která počítá hash mapy. Projdeme si tento kód řádek po řádku.

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

Tyto dva řádky importují dvě funkce ze standardní knihovny Zokrates (opens in a new tab). První funkce (opens in a new tab) je hash Poseidon (opens in a new tab). Přijímá pole prvků field (opens in a new tab) a vrací field.

Prvek tělesa (field) v Zokrates je obvykle kratší než 256 bitů, ale ne o moc. Pro zjednodušení kódu omezíme mapu na maximálně 512 bitů a hashujeme pole čtyř prvků tělesa, přičemž v každém z nich použijeme pouze 128 bitů. Funkce pack128 (opens in a new tab) pro tento účel mění pole 128 bitů na field.

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

Tento řádek začíná definici funkce. hashMap získává jeden parametr s názvem map, což je dvourozměrné pole typu bool(ean). Velikost mapy je width+2 krát height+2 z důvodů, které jsou vysvětleny níže.

Můžeme použít ${width+2} a ${height+2}, protože programy Zokrates jsou v této aplikaci uloženy jako šablonové řetězce (template strings) (opens in a new tab). Kód mezi ${ a } je vyhodnocován JavaScriptem, a tímto způsobem lze program použít pro různé velikosti map. Parametr mapy má kolem dokola okraj o šířce jednoho políčka bez jakýchkoli bomb, což je důvod, proč musíme k šířce a výšce přičíst dva.

Návratovou hodnotou je field, který obsahuje hash.

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

Mapa je dvourozměrná. Funkce pack128 však nefunguje s dvourozměrnými poli. Proto nejprve mapu zploštíme do 512bajtového pole pomocí map1d. Ve výchozím nastavení jsou proměnné v Zokrates konstanty, ale my potřebujeme tomuto poli přiřazovat hodnoty ve smyčce, takže ho definujeme jako mut (opens in a new tab).

Pole musíme inicializovat, protože Zokrates nemá undefined. Výraz [false; 512] znamená pole 512 hodnot false (opens in a new tab).

u32 mut counter = 0;

Potřebujeme také počítadlo, abychom rozlišili mezi bity, které jsme již v map1d vyplnili, a těmi, které ještě ne.

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

Takhle se v Zokrates deklaruje smyčka for (opens in a new tab). Smyčka for v Zokrates musí mít pevné hranice, protože ačkoli se jeví jako smyčka, kompilátor ji ve skutečnosti „rozbalí“ (unrolls). Výraz ${width+2} je konstanta v době kompilace, protože width je nastaveno kódem v TypeScriptu předtím, než zavolá kompilátor.

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

Pro každé místo na mapě vložte tuto hodnotu do pole map1d a zvyšte počítadlo.

field[4] hashMe = [
        pack128(map1d[0..128]),
        pack128(map1d[128..256]),
        pack128(map1d[256..384]),
        pack128(map1d[384..512])
    ];

Pomocí pack128 vytvoříme pole čtyř hodnot field z map1d. V Zokrates array[a..b] znamená výřez pole, který začíná na a a končí na b-1.

return poseidon(hashMe);
}

Použijte poseidon k převodu tohoto pole na hash.

Hashovací program

Server musí volat hashMap přímo, aby vytvořil identifikátory hry. Zokrates však může při spuštění programu volat pouze funkci main, takže vytvoříme program s funkcí main, která volá hashovací funkci.

${hashFragment}

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

Program pro kopání

Toto je srdce části aplikace s nulovým vědomím, kde vytváříme důkazy, které se používají k ověření výsledků kopání.

${hashFragment}

// Počet min na pozici (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 };
}

Proč okraj mapy

Důkazy s nulovou znalostí používají aritmetické obvody (opens in a new tab), které nemají jednoduchý ekvivalent k příkazu if. Místo toho používají ekvivalent podmíněného operátoru (opens in a new tab). Pokud a může být buď nula, nebo jedna, můžete vypočítat if a { b } else { c } jako ab+(1-a)c.

Z tohoto důvodu příkaz if v Zokrates vždy vyhodnocuje obě větve. Pokud máte například tento kód:

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

Skončí chybou, protože potřebuje vypočítat arr[10], i když bude tato hodnota později vynásobena nulou.

To je důvod, proč potřebujeme kolem celé mapy okraj o šířce jednoho políčka. Potřebujeme vypočítat celkový počet min kolem daného místa, a to znamená, že musíme vidět místo o jeden řádek výše a níže, nalevo a napravo od místa, kde kopeme. Což znamená, že tato místa musí existovat v poli mapy, které je Zokrates poskytnuto.

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

Ve výchozím nastavení důkazy Zokrates zahrnují své vstupy. Není k ničemu vědět, že kolem nějakého místa je pět min, pokud nevíte, o jaké místo se přesně jedná (a nemůžete to jen tak spárovat se svým požadavkem, protože pak by dokazovatel mohl použít jiné hodnoty a neříct vám o tom). My však potřebujeme udržet mapu v tajnosti, a přitom ji poskytnout Zokrates. Řešením je použít parametr private, tedy takový, který důkaz neodhalí.

To otevírá další prostor pro zneužití. Dokazovatel by mohl použít správné souřadnice, ale vytvořit mapu s libovolným počtem min kolem daného místa, a případně i na samotném místě. Abychom tomuto zneužití zabránili, zajistíme, aby důkaz s nulovou znalostí obsahoval hash mapy, což je identifikátor hry.

return (hashMap(map),

Návratovou hodnotou je zde n-tice (tuple), která obsahuje pole hashe mapy a také výsledek kopání.

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

Používáme 255 jako speciální hodnotu pro případ, že se na samotném místě nachází 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)
         }
   );
}

Pokud hráč nenarazil na minu, sečtěte počty min v oblasti kolem daného místa a vraťte tento výsledek.

Použití Zokrates z TypeScriptu

Zokrates má rozhraní příkazového řádku, ale v tomto programu jej používáme v kódu v TypeScriptu (opens in a new tab).

Knihovna, která obsahuje definice Zokrates, se nazývá zero-knowledge.ts (opens in a new tab).

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

Importujte vazby Zokrates pro JavaScript (opens in a new tab). Potřebujeme pouze funkci initialize (opens in a new tab), protože vrací promise, který se vyhodnotí na všechny definice Zokrates.

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

Podobně jako u samotného Zokrates exportujeme také pouze jednu funkci, která je rovněž asynchronní (opens in a new tab). Když se nakonec vrátí, poskytne několik funkcí, jak uvidíme níže.

const zokrates = await zokratesInitialize()

Inicializujte Zokrates a získejte z knihovny vše, co potřebujeme.

Dále tu máme hashovací funkci a dva programy Zokrates, které jsme viděli výše.

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

Zde tyto programy zkompilujeme.

// Vytvořte klíče pro ověření s nulovým vědomím.
// V produkčním systému byste chtěli použít ceremonii nastavení.
// (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

V produkčním systému bychom možná použili složitější ceremonii nastavení (setup ceremony) (opens in a new tab), ale pro ukázku to stačí. Není problém, že uživatelé mohou znát klíč dokazovatele – stále jej nemohou použít k dokazování věcí, pokud nejsou pravdivé. Protože specifikujeme entropii (druhý parametr, ""), výsledky budou vždy stejné.

Poznámka: Kompilace programů Zokrates a vytváření klíčů jsou pomalé procesy. Není nutné je opakovat pokaždé, pouze při změně velikosti mapy. V produkčním systému byste je provedli jednou a poté uložili výstup. Jediný důvod, proč to zde nedělám, je kvůli jednoduchosti.

calculateMapHash

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

Funkce computeWitness (opens in a new tab) ve skutečnosti spouští program Zokrates. Vrací strukturu se dvěma poli: output, což je výstup programu jako řetězec JSON, a witness, což jsou informace potřebné k vytvoření důkazu s nulovou znalostí o výsledku. Zde potřebujeme pouze výstup.

Výstupem je řetězec ve formátu "31337", což je desetinné číslo uzavřené v uvozovkách. Ale výstup, který potřebujeme pro viem, je hexadecimální číslo ve formátu 0x60A7. Takže použijeme .slice(1,-1) k odstranění uvozovek a poté BigInt k převedení zbývajícího řetězce, což je desetinné číslo, na BigInt (opens in a new tab). .toString(16) převede tento BigInt na hexadecimální řetězec a "0x"+ přidá značku pro hexadecimální čísla.

// Vykopejte a vraťte důkaz s nulovou znalostí výsledku
// (kód na straně serveru)

Důkaz s nulovou znalostí zahrnuje veřejné vstupy (x a y) a výsledky (hash mapy a počet bomb).

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

V Zokrates je problém zkontrolovat, zda je index mimo hranice, takže to děláme zde.

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

Spusťte program pro kopání.

        const proof = zokrates.generateProof(
            digCompiled.program,
            runResults.witness,
            proverKey)

        return proof
    }

Použijte generateProof (opens in a new tab) a vraťte důkaz.

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

Ověřovatel v Solidity, chytrý kontrakt, který můžeme nasadit na blockchain a použít k ověření důkazů generovaných pomocí digCompiled.program.

    return {
        zkDig,
        calculateMapHash,
        solidityVerifier,
    }
}

Nakonec vraťte vše, co by mohl potřebovat jiný kód.

Bezpečnostní testy

Bezpečnostní testy jsou důležité, protože chyba ve funkčnosti se nakonec projeví. Pokud je však aplikace nezabezpečená, pravděpodobně to zůstane dlouho skryto, než se to odhalí tím, že někdo bude podvádět a získá prostředky, které patří ostatním.

Oprávnění

V této hře existuje jedna privilegovaná entita, a to server. Je to jediný uživatel, který má povoleno volat funkce v ServerSystem (opens in a new tab). Můžeme použít cast (opens in a new tab) k ověření, že volání funkcí s řízeným přístupem jsou povolena pouze pro účet serveru.

Soukromý klíč serveru je v setupNetwork.ts (opens in a new tab).

  1. Na počítači, na kterém běží anvil (blockchain), nastavte tyto proměnné prostředí.

    WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b
    UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
    AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
    
  2. Použijte cast k pokusu o nastavení adresy ověřovatele jako neoprávněné adresy.

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

    Nejenže cast nahlásí selhání, ale můžete také otevřít MUD Dev Tools ve hře v prohlížeči, kliknout na Tables a vybrat app__VerifierAddress. Uvidíte, že adresa není nula.

  3. Nastavte adresu ověřovatele jako adresu serveru.

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

    Adresa v app__VerifiedAddress by nyní měla být nula.

Všechny funkce MUD ve stejném System procházejí stejným řízením přístupu, takže tento test považuji za dostatečný. Pokud ne, můžete zkontrolovat ostatní funkce v ServerSystem (opens in a new tab).

Zneužití s nulovým vědomím

Matematika pro ověření Zokrates je nad rámec tohoto tutoriálu (a mých schopností). Můžeme však provést různé kontroly kódu s nulovým vědomím, abychom ověřili, že pokud není proveden správně, selže. Všechny tyto testy budou vyžadovat, abychom změnili zero-knowledge.ts (opens in a new tab) a restartovali celou aplikaci. Nestačí restartovat proces serveru, protože to uvede aplikaci do nemožného stavu (hráč má rozehranou hru, ale hra již není pro server dostupná).

Špatná odpověď

Nejjednodušší možností je poskytnout špatnou odpověď v důkazu s nulovou znalostí. Abychom to udělali, půjdeme do zkDig a upravíme řádek 91 (opens in a new tab):

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

To znamená, že budeme vždy tvrdit, že je tam jedna bomba, bez ohledu na správnou odpověď. Zkuste si zahrát s touto verzí a na kartě server na obrazovce pnpm dev uvidíte tuto chybu:

cause: {
        code: 3,
        message: 'execution reverted: revert: Zero knowledge verification fail',
        data: '0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000
000000000000000000000000000000000000000000000000205a65726f206b6e6f776c6564676520766572696669636174696f6
e206661696c'
      },

Takže tento druh podvodu selže.

Špatný důkaz

Co se stane, když poskytneme správné informace, ale budeme mít jen špatná data důkazu? Nyní nahraďte řádek 91 tímto:

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")],
}

Stále to selže, ale nyní to selže bez udání důvodu, protože k tomu dojde během volání ověřovatele.

Jak může uživatel ověřit kód s nulovou důvěrou?

Chytré kontrakty se ověřují poměrně snadno. Vývojář obvykle publikuje zdrojový kód do prohlížeče bloků a prohlížeč bloků ověří, že se zdrojový kód skutečně zkompiluje do kódu v transakci nasazení kontraktu. V případě MUD System je to o něco složitější (opens in a new tab), ale ne o moc.

S nulovým vědomím je to těžší. Ověřovatel obsahuje některé konstanty a provádí na nich výpočty. To vám neřekne, co se dokazuje.

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

Řešením, alespoň dokud se prohlížeče bloků nedostanou k přidání ověřování Zokrates do svých uživatelských rozhraní, je, aby vývojáři aplikací zpřístupnili programy Zokrates a aby si je alespoň někteří uživatelé sami zkompilovali s příslušným ověřovacím klíčem.

Chcete-li tak učinit:

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

  2. Vytvořte soubor dig.zok s programem Zokrates. Níže uvedený kód předpokládá, že jste zachovali původní velikost mapy, 10x5.

  3. Zkompilujte kód Zokrates a vytvořte ověřovací klíč. Ověřovací klíč musí být vytvořen se stejnou entropií, jaká byla použita na původním serveru, v tomto případě s prázdným řetězcem (opens in a new tab).

    zokrates compile --input dig.zok
    zokrates setup -e ""
    
  4. Vytvořte si vlastní ověřovatel v Solidity a ověřte, že je funkčně identický s tím na blockchainu (server přidává komentář, ale to není důležité).

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

Rozhodnutí o návrhu

V každé dostatečně složité aplikaci existují protichůdné cíle návrhu, které vyžadují kompromisy. Podívejme se na některé z těchto kompromisů a na to, proč je současné řešení vhodnější než jiné možnosti.

Proč s nulovým vědomím

Pro hledání min ve skutečnosti nepotřebujete řešení s nulovým vědomím. Server může vždy držet mapu a po skončení hry ji celou odhalit. Na konci hry pak může chytrý kontrakt vypočítat hash mapy, ověřit, zda se shoduje, a pokud ne, penalizovat server nebo hru zcela ignorovat.

Toto jednodušší řešení jsem nepoužil, protože funguje pouze pro krátké hry s dobře definovaným koncovým stavem. Když je hra potenciálně nekonečná (jako je tomu v případě autonomních světů (opens in a new tab)), potřebujete řešení, které dokazuje stav, aniž by ho odhalilo.

Jako tutoriál tento článek potřeboval krátkou hru, která je snadno pochopitelná, ale tato technika je nejužitečnější pro delší hry.

Proč Zokrates?

Zokrates (opens in a new tab) není jedinou dostupnou knihovnou s nulovým vědomím, ale je podobný běžnému imperativnímu (opens in a new tab) programovacímu jazyku a podporuje booleovské proměnné.

Pro vaši aplikaci s jinými požadavky možná upřednostníte použití Circum (opens in a new tab) nebo Cairo (opens in a new tab).

Kdy kompilovat Zokrates

V tomto programu kompilujeme programy Zokrates při každém spuštění serveru (opens in a new tab). To je zjevně plýtvání zdroji, ale toto je tutoriál optimalizovaný pro jednoduchost.

Kdybych psal produkční aplikaci, zkontroloval bych, zda mám soubor se zkompilovanými programy Zokrates pro tuto velikost minového pole, a pokud ano, použil bych ho. Totéž platí pro nasazení kontraktu ověřovatele onchain.

Vytvoření klíčů ověřovatele a dokazovatele

Vytvoření klíčů (opens in a new tab) je další čistý výpočet, který pro danou velikost minového pole není nutné provádět více než jednou. Opět se to dělá pouze jednou kvůli jednoduchosti.

Navíc bychom mohli použít ceremonii nastavení (setup ceremony) (opens in a new tab). Výhodou ceremonie nastavení je, že k podvádění u důkazu s nulovou znalostí potřebujete buď entropii, nebo nějaký mezivýsledek od každého účastníka. Pokud je alespoň jeden účastník ceremonie čestný a tyto informace smaže, jsou důkazy s nulovou znalostí v bezpečí před určitými útoky. Neexistuje však žádný mechanismus, jak ověřit, že informace byly smazány odevšad. Pokud jsou důkazy s nulovou znalostí kriticky důležité, budete se chtít ceremonie nastavení zúčastnit.

Zde spoléháme na perpetual powers of tau (opens in a new tab), kterého se zúčastnily desítky lidí. Je to pravděpodobně dostatečně bezpečné a mnohem jednodušší. Během vytváření klíčů také nepřidáváme entropii, což uživatelům usnadňuje ověření konfigurace s nulovým vědomím.

Kde ověřovat

Důkazy s nulovou znalostí můžeme ověřovat buď onchain (což stojí gas), nebo v klientovi (pomocí verify (opens in a new tab)). Vybral jsem první možnost, protože vám to umožňuje ověřit ověřovatele jednou a pak důvěřovat, že se nezmění, dokud adresa kontraktu zůstane stejná. Pokud by se ověřování provádělo v klientovi, museli byste ověřovat kód, který obdržíte při každém stažení klienta.

Navíc, ačkoli je tato hra pro jednoho hráče, mnoho blockchainových her je pro více hráčů. Ověřování onchain znamená, že důkaz s nulovou znalostí ověříte pouze jednou. Provádění v klientovi by vyžadovalo, aby každý klient prováděl ověření nezávisle.

Zploštit mapu v TypeScriptu nebo v Zokrates?

Obecně platí, že když lze zpracování provést buď v TypeScriptu, nebo v Zokrates, je lepší to udělat v TypeScriptu, který je mnohem rychlejší a nevyžaduje důkazy s nulovou znalostí. To je například důvod, proč Zokrates neposkytujeme hash a nenutíme ho ověřovat jeho správnost. Hashování se musí provádět uvnitř Zokrates, ale shoda mezi vráceným hashem a hashem onchain může proběhnout mimo něj.

Nicméně stále zplošťujeme mapu v Zokrates (opens in a new tab), ačkoli jsme to mohli udělat v TypeScriptu. Důvodem je, že ostatní možnosti jsou podle mého názoru horší.

  • Poskytnout kódu Zokrates jednorozměrné pole booleovských hodnot a použít výraz jako x*(height+2) +y k získání dvourozměrné mapy. To by kód (opens in a new tab) poněkud zkomplikovalo, takže jsem se rozhodl, že nárůst výkonu za to v tutoriálu nestojí.

  • Poslat do Zokrates jak jednorozměrné, tak dvourozměrné pole. Toto řešení nám však nic nepřinese. Kód Zokrates by musel ověřit, že poskytnuté jednorozměrné pole je skutečně správnou reprezentací dvourozměrného pole. Takže by nedošlo k žádnému nárůstu výkonu.

  • Zploštit dvourozměrné pole v Zokrates. To je nejjednodušší možnost, proto jsem si ji vybral.

Kde ukládat mapy

V této aplikaci je gamesInProgress (opens in a new tab) jednoduše proměnná v paměti. To znamená, že pokud váš server spadne a je nutné ho restartovat, všechny uložené informace se ztratí. Nejenže hráči nemohou pokračovat ve hře, ale nemohou ani začít novou hru, protože onchain komponenta si myslí, že stále mají rozehranou hru.

To je zjevně špatný návrh pro produkční systém, ve kterém byste tyto informace ukládali do databáze. Jediný důvod, proč jsem zde použil proměnnou, je ten, že se jedná o tutoriál a jednoduchost je hlavním hlediskem.

Závěr: Za jakých podmínek je tato technika vhodná?

Takže nyní víte, jak napsat hru se serverem, který ukládá tajný stav, jenž nepatří onchain. Ale v jakých případech byste to měli dělat? Existují dva hlavní faktory ke zvážení.

  • Dlouhotrvající hra: Jak bylo zmíněno výše, v krátké hře můžete stav jednoduše zveřejnit po jejím skončení a nechat vše ověřit až poté. To ale není možné, když hra trvá dlouho nebo neurčitou dobu a stav musí zůstat tajný.

  • Je přijatelná určitá míra centralizace: Důkazy s nulovou znalostí mohou ověřit integritu, tedy že daná entita nefalšuje výsledky. Co ale nedokážou, je zajistit, že tato entita bude stále dostupná a bude odpovídat na zprávy. V situacích, kdy musí být decentralizovaná i dostupnost, nejsou důkazy s nulovou znalostí dostatečným řešením a budete potřebovat vícestranné výpočty (opens in a new tab).

Více z mé práce najdete zde (opens in a new tab).

Poděkování

  • Alvaro Alonso si přečetl koncept tohoto článku a objasnil mi některá má nedorozumění ohledně Zokrates.

Za případné zbývající chyby nesu odpovědnost já.