Usando conhecimento zero para um estado secreto
Não há segredos na blockchain. Tudo o que é publicado na blockchain é aberto para todos lerem. Isso é necessário, porque a blockchain se baseia no fato de que qualquer pessoa pode verificá-la. No entanto, os jogos frequentemente dependem de um estado secreto. Por exemplo, o jogo de campo minado (opens in a new tab) não faz o menor sentido se você puder simplesmente acessar um explorador de blocos e ver o mapa.
A solução mais simples é usar um componente de servidor para manter o estado secreto. No entanto, o motivo pelo qual usamos a blockchain é para evitar trapaças por parte do desenvolvedor do jogo. Precisamos garantir a honestidade do componente do servidor. O servidor pode fornecer um hash do estado e usar provas de conhecimento zero para provar que o estado usado para calcular o resultado de uma jogada é o correto.
Após ler este artigo, você saberá como criar esse tipo de servidor que mantém o estado secreto, um cliente para mostrar o estado e um componente onchain para a comunicação entre os dois. As principais ferramentas que usaremos serão:
| Ferramenta | Propósito | Verificado na versão |
|---|---|---|
| Zokrates (opens in a new tab) | Provas de conhecimento zero e sua verificação | 1.1.9 |
| TypeScript (opens in a new tab) | Linguagem de programação tanto para o servidor quanto para o cliente | 5.4.2 |
| Node (opens in a new tab) | Executar o servidor | 20.18.2 |
| Viem (opens in a new tab) | Comunicação com a blockchain | 2.9.20 |
| MUD (opens in a new tab) | Gerenciamento de dados onchain | 2.0.12 |
| React (opens in a new tab) | Interface de usuário do cliente | 18.2.0 |
| Vite (opens in a new tab) | Servir o código do cliente | 4.2.1 |
Exemplo do Campo Minado
Campo Minado (opens in a new tab) é um jogo que inclui um mapa secreto com um campo minado. O jogador escolhe cavar em um local específico. Se esse local tiver uma mina, o jogo acaba. Caso contrário, o jogador obtém o número de minas nos oito quadrados ao redor desse local.
Este aplicativo é escrito usando MUD (opens in a new tab), um framework que nos permite armazenar dados onchain usando um banco de dados de chave-valor (opens in a new tab) e sincronizar esses dados automaticamente com componentes offchain. Além da sincronização, o MUD facilita o fornecimento de controle de acesso e permite que outros usuários estendam (opens in a new tab) nosso aplicativo sem permissão.
Executando o exemplo do campo minado
Para executar o exemplo do campo minado:
-
Certifique-se de ter os pré-requisitos instalados (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). -
Clone o repositório.
git clone https://github.com/qbzzt/20240901-secret-state.git -
Instale os pacotes.
cd 20240901-secret-state/ pnpm install npm install -g mprocsSe o Foundry foi instalado como parte do
pnpm install, você precisará reiniciar o shell da linha de comando. -
Compile os contratos
cd packages/contracts forge build cd ../.. -
Inicie o programa (incluindo uma blockchain anvil (opens in a new tab)) e aguarde.
mprocsObserve que a inicialização demora muito. Para ver o progresso, primeiro use a seta para baixo para rolar até a aba contracts para ver os contratos MUD sendo implantados. Quando você receber a mensagem Waiting for file changes…, os contratos estarão implantados e o progresso adicional acontecerá na aba server. Lá, você aguarda até receber a mensagem Verifier address: 0x.....
Se esta etapa for bem-sucedida, você verá a tela do
mprocs, com os diferentes processos à esquerda e a saída do console para o processo atualmente selecionado à direita.Se houver um problema com o
mprocs, você pode executar os quatro processos manualmente, cada um em sua própria janela de linha de 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
-
-
Agora você pode navegar até o cliente (opens in a new tab), clicar em New Game e começar a jogar.
Tabelas
Precisamos de várias tabelas (opens in a new tab) onchain.
-
Configuration: Esta tabela é um singleton, não possui chave e tem um único registro. É usada para armazenar informações de configuração do jogo:height: A altura de um campo minadowidth: A largura de um campo minadonumberOfBombs: O número de bombas em cada campo minado
-
VerifierAddress: Esta tabela também é um singleton. É usada para armazenar uma parte da configuração, o endereço do contrato verificador (verifier). Poderíamos ter colocado essa informação na tabelaConfiguration, mas ela é definida por um componente diferente, o servidor, então é mais fácil colocá-la em uma tabela separada. -
PlayerGame: A chave é o endereço do jogador. Os dados são:gameId: valor de 32 bytes que é o hash do mapa em que o jogador está jogando (o identificador do jogo).win: um booleano que indica se o jogador venceu o jogo.lose: um booleano que indica se o jogador perdeu o jogo.digNumber: o número de escavações bem-sucedidas no jogo.
-
GamePlayer: Esta tabela contém o mapeamento reverso, degameIdpara o endereço do jogador. -
Map: A chave é uma tupla de três valores:gameId: valor de 32 bytes que é o hash do mapa em que o jogador está jogando (o identificador do jogo).- coordenada
x - coordenada
y
O valor é um único número. É 255 se uma bomba foi detectada. Caso contrário, é o número de bombas ao redor desse local mais um. Não podemos usar apenas o número de bombas, porque, por padrão, todo o armazenamento na EVM e todos os valores de linha no MUD são zero. Precisamos distinguir entre "o jogador ainda não cavou aqui" e "o jogador cavou aqui e descobriu que há zero bombas ao redor".
Além disso, a comunicação entre o cliente e o servidor acontece por meio do componente onchain. Isso também é implementado usando tabelas.
PendingGame: Solicitações não atendidas para iniciar um novo jogo.PendingDig: Solicitações não atendidas para cavar em um local específico em um jogo específico. Esta é uma tabela offchain (opens in a new tab), o que significa que ela não é gravada no armazenamento da EVM, sendo legível apenas offchain usando eventos.
Fluxos de execução e dados
Esses fluxos coordenam a execução entre o cliente, o componente onchain e o servidor.
Inicialização
Quando você executa mprocs, estas etapas acontecem:
-
mprocs(opens in a new tab) executa quatro componentes:- Anvil (opens in a new tab), que executa uma blockchain local
- Contracts (opens in a new tab), que compila (se necessário) e implanta os contratos para o MUD
- Client (opens in a new tab), que executa o Vite (opens in a new tab) para servir a interface do usuário e o código do cliente para navegadores da web.
- Server (opens in a new tab), que executa as ações do servidor
-
O pacote
contractsimplanta os contratos MUD e, em seguida, executa o scriptPostDeploy.s.sol(opens in a new tab). Este script define a configuração. O código do GitHub especifica um campo minado de 10x5 com oito minas nele (opens in a new tab). -
O servidor (opens in a new tab) começa configurando o MUD (opens in a new tab). Entre outras coisas, isso ativa a sincronização de dados, para que uma cópia das tabelas relevantes exista na memória do servidor.
-
O servidor inscreve uma função para ser executada quando a tabela
Configurationfor alterada (opens in a new tab). Esta função (opens in a new tab) é chamada após oPostDeploy.s.solser executado e modificar a tabela. -
Quando a função de inicialização do servidor tem a configuração, ela chama
zkFunctions(opens in a new tab) para inicializar a parte de conhecimento zero do servidor. Isso não pode acontecer até obtermos a configuração porque as funções de conhecimento zero devem ter a largura e a altura do campo minado como constantes. -
Após a parte de conhecimento zero do servidor ser inicializada, o próximo passo é implantar o contrato de verificação de conhecimento zero na blockchain (opens in a new tab) e definir o endereço do verificador no MUD.
-
Por fim, nos inscrevemos para receber atualizações para vermos quando um jogador solicitar iniciar um novo jogo (opens in a new tab) ou cavar em um jogo existente (opens in a new tab).
Novo jogo
Isso é o que acontece quando o jogador solicita um novo jogo.
-
Se não houver nenhum jogo em andamento para este jogador, ou se houver um, mas com um gameId igual a zero, o cliente exibirá um botão de novo jogo (opens in a new tab). Quando o usuário pressiona este botão, o React executa a função
newGame(opens in a new tab). -
newGame(opens in a new tab) é uma chamada deSystem. No MUD, todas as chamadas são roteadas através do contratoWorlde, na maioria dos casos, você chama<namespace>__<function name>. Neste caso, a chamada é paraapp__newGame, que o MUD então roteia paranewGameemGameSystem(opens in a new tab). -
A função onchain verifica se o jogador não tem um jogo em andamento e, se não houver, adiciona a solicitação à tabela
PendingGame(opens in a new tab). -
O servidor detecta a alteração em
PendingGamee executa a função inscrita (opens in a new tab). Esta função chamanewGame(opens in a new tab), que por sua vez chamacreateGame(opens in a new tab). -
A primeira coisa que
createGamefaz é criar um mapa aleatório com o número apropriado de minas (opens in a new tab). Em seguida, ele chamamakeMapBorders(opens in a new tab) para criar um mapa com bordas em branco, o que é necessário para o Zokrates. Por fim,createGamechamacalculateMapHash, para obter o hash do mapa, que é usado como o ID do jogo. -
A função
newGameadiciona o novo jogo agamesInProgress. -
A última coisa que o servidor faz é chamar
app__newGameResponse(opens in a new tab), que é onchain. Esta função está em umSystemdiferente,ServerSystem(opens in a new tab), para habilitar o controle de acesso. O controle de acesso é definido no arquivo de configuração do MUD (opens in a new tab),mud.config.ts(opens in a new tab).A lista de acesso permite apenas que um único endereço chame o
System. Isso restringe o acesso às funções do servidor a um único endereço, para que ninguém possa se passar pelo servidor. -
O componente onchain atualiza as tabelas relevantes:
- Cria o jogo em
PlayerGame. - Define o mapeamento reverso em
GamePlayer. - Remove a solicitação de
PendingGame.
- Cria o jogo em
-
O servidor identifica a alteração em
PendingGame, mas não faz nada porquewantsGame(opens in a new tab) é falso. -
No cliente,
gameRecord(opens in a new tab) é definido para a entradaPlayerGamedo endereço do jogador. QuandoPlayerGamemuda,gameRecordtambém muda. -
Se houver um valor em
gameRecorde o jogo não tiver sido ganho ou perdido, o cliente exibe o mapa (opens in a new tab).
Cavar
-
O jogador clica no botão da célula do mapa (opens in a new tab), o que chama a função
dig(opens in a new tab). Esta função chamadigonchain (opens in a new tab). -
O componente onchain realiza uma série de verificações de sanidade (opens in a new tab) e, se for bem-sucedido, adiciona a solicitação de escavação a
PendingDig(opens in a new tab). -
O servidor detecta a alteração em
PendingDig(opens in a new tab). Se for válida (opens in a new tab), ele chama o código de conhecimento zero (opens in a new tab) (explicado abaixo) para gerar tanto o resultado quanto uma prova de que é válido. -
O servidor (opens in a new tab) chama
digResponse(opens in a new tab) onchain. -
digResponsefaz duas coisas. Primeiro, ele verifica a prova de conhecimento zero (opens in a new tab). Em seguida, se a prova for confirmada, ele chamaprocessDigResult(opens in a new tab) para realmente processar o resultado. -
processDigResultverifica se o jogo foi perdido (opens in a new tab) ou ganho (opens in a new tab) e atualizaMap, o mapa onchain (opens in a new tab). -
O cliente capta as atualizações automaticamente e atualiza o mapa exibido ao jogador (opens in a new tab) e, se aplicável, informa ao jogador se é uma vitória ou uma derrota.
Usando o Zokrates
Nos fluxos explicados acima, pulamos as partes de conhecimento zero, tratando-as como uma caixa preta. Agora vamos abri-la e ver como esse código é escrito.
Gerando o hash do mapa
Podemos usar este código JavaScript (opens in a new tab) para implementar o Poseidon (opens in a new tab), a função de hash do Zokrates que usamos. No entanto, embora isso fosse mais rápido, também seria mais complicado do que simplesmente usar a função de hash do Zokrates para fazer isso. Este é um tutorial e, portanto, o código é otimizado para simplicidade, não para desempenho. Portanto, precisamos de dois programas Zokrates diferentes, um apenas para calcular o hash de um mapa (hash) e outro para realmente criar uma prova de conhecimento zero do resultado da escavação em um local no mapa (dig).
A função de hash
Esta é a função que calcula o hash de um mapa. Vamos analisar este código linha por linha.
import "hashes/poseidon/poseidon.zok" as poseidon;
import "utils/pack/bool/pack128.zok" as pack128;
Estas duas linhas importam duas funções da biblioteca padrão do Zokrates (opens in a new tab). A primeira função (opens in a new tab) é um hash Poseidon (opens in a new tab). Ela recebe um array de elementos field (opens in a new tab) e retorna um field.
O elemento de campo (field) no Zokrates normalmente tem menos de 256 bits de comprimento, mas não por muito. Para simplificar o código, restringimos o mapa a ter até 512 bits e fazemos o hash de um array de quatro campos, e em cada campo usamos apenas 128 bits. A função pack128 (opens in a new tab) transforma um array de 128 bits em um field para esse propósito.
def hashMap(bool[${width+2}][${height+2}] map) -> field {
Esta linha inicia a definição de uma função. hashMap recebe um único parâmetro chamado map, um array bidimensional de bool(eanos). O tamanho do mapa é width+2 por height+2 por razões que são explicadas abaixo.
Podemos usar ${width+2} e ${height+2} porque os programas Zokrates são armazenados neste aplicativo como template strings (opens in a new tab). O código entre ${ e } é avaliado pelo JavaScript e, dessa forma, o programa pode ser usado para diferentes tamanhos de mapa. O parâmetro do mapa tem uma borda de um local de largura ao redor dele sem nenhuma bomba, o que é a razão pela qual precisamos adicionar dois à largura e à altura.
O valor de retorno é um field que contém o hash.
bool[512] mut map1d = [false; 512];
O mapa é bidimensional. No entanto, a função pack128 não funciona com arrays bidimensionais. Então, primeiro achatamos o mapa em um array de 512 bytes, usando map1d. Por padrão, as variáveis do Zokrates são constantes, mas precisamos atribuir valores a este array em um loop, então o definimos como mut (opens in a new tab).
Precisamos inicializar o array porque o Zokrates não tem undefined. A expressão [false; 512] significa um array de 512 valores false (opens in a new tab).
u32 mut counter = 0;
Também precisamos de um contador para distinguir entre os bits que já preenchemos em map1d e os que não preenchemos.
for u32 x in 0..${width+2} {
É assim que você declara um loop for (opens in a new tab) no Zokrates. Um loop for no Zokrates precisa ter limites fixos, porque, embora pareça ser um loop, o compilador na verdade o "desenrola" (unrolls). A expressão ${width+2} é uma constante em tempo de compilação porque width é definido pelo código TypeScript antes de chamar o compilador.
for u32 y in 0..${height+2} {
map1d[counter] = map[x][y];
counter = counter+1;
}
}
Para cada local no mapa, coloque esse valor no array map1d e incremente o contador.
field[4] hashMe = [
pack128(map1d[0..128]),
pack128(map1d[128..256]),
pack128(map1d[256..384]),
pack128(map1d[384..512])
];
O pack128 para criar um array de quatro valores field a partir de map1d. No Zokrates, array[a..b] significa a fatia (slice) do array que começa em a e termina em b-1.
return poseidon(hashMe);
}
Use poseidon para converter este array em um hash.
O programa de hash
O servidor precisa chamar hashMap diretamente para criar identificadores de jogo. No entanto, o Zokrates só pode chamar a função main em um programa para iniciar, então criamos um programa com um main que chama a função de hash.
${hashFragment}
def main(bool[${width+2}][${height+2}] map) -> field {
return hashMap(map);
}
O programa de escavação
Este é o coração da parte de conhecimento zero do aplicativo, onde produzimos as provas que são usadas para verificar os resultados da escavação.
${hashFragment}
// O número de minas no local (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 };
}
Por que a borda do mapa
As provas de conhecimento zero usam circuitos aritméticos (opens in a new tab), que não têm um equivalente fácil para uma instrução if. Em vez disso, elas usam o equivalente ao operador condicional (opens in a new tab). Se a puder ser zero ou um, você pode calcular if a { b } else { c } como ab+(1-a)c.
Por causa disso, uma instrução if no Zokrates sempre avalia ambas as ramificações. Por exemplo, se você tiver este código:
bool[5] arr = [false; 5];
u32 index=10;
return if index>4 { 0 } else { arr[index] }
Ele dará erro, porque precisa calcular arr[10], mesmo que esse valor seja posteriormente multiplicado por zero.
Esta é a razão pela qual precisamos de uma borda de um local de largura ao redor de todo o mapa. Precisamos calcular o número total de minas ao redor de um local, e isso significa que precisamos ver o local uma linha acima e abaixo, à esquerda e à direita, do local onde estamos escavando. O que significa que esses locais devem existir no array do mapa fornecido ao Zokrates.
def main(private bool[${width+2}][${height+2}] map, u32 x, u32 y) -> (field, u8) {
Por padrão, as provas do Zokrates incluem suas entradas. Não adianta saber que há cinco minas ao redor de um ponto a menos que você realmente saiba qual é o ponto (e você não pode simplesmente combiná-lo com sua solicitação, porque então o provador poderia usar valores diferentes e não lhe contar sobre isso). No entanto, precisamos manter o mapa em segredo, ao mesmo tempo em que o fornecemos ao Zokrates. A solução é usar um parâmetro private, um que não seja revelado pela prova.
Isso abre outro caminho para abusos. O provador poderia usar as coordenadas corretas, mas criar um mapa com qualquer número de minas ao redor do local e, possivelmente, no próprio local. Para evitar esse abuso, fazemos com que a prova de conhecimento zero inclua o hash do mapa, que é o identificador do jogo.
return (hashMap(map),
O valor de retorno aqui é uma tupla que inclui o array de hash do mapa, bem como o resultado da escavação.
if map2mineCount(map, x, y) > 0 { 0xFF } else {
Usamos 255 como um valor especial caso o próprio local tenha uma 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 o jogador não atingiu uma mina, adicione as contagens de minas para a área ao redor do local e retorne isso.
Usando o Zokrates a partir do TypeScript
O Zokrates tem uma interface de linha de comando, mas neste programa nós o usamos no código TypeScript (opens in a new tab).
A biblioteca que contém as definições do Zokrates é chamada zero-knowledge.ts (opens in a new tab).
import { initialize as zokratesInitialize } from "zokrates-js"
Importe as ligações (bindings) JavaScript do Zokrates (opens in a new tab). Precisamos apenas da função initialize (opens in a new tab) porque ela retorna uma promessa que é resolvida com todas as definições do Zokrates.
export const zkFunctions = async (width: number, height: number) : Promise<any> => {
Semelhante ao próprio Zokrates, também exportamos apenas uma função, que também é assíncrona (opens in a new tab). Quando ela finalmente retorna, ela fornece várias funções, como veremos abaixo.
const zokrates = await zokratesInitialize()
Inicialize o Zokrates, obtenha tudo o que precisamos da biblioteca.
const hashFragment = `
import "utils/pack/bool/pack128.zok" as pack128;
import "hashes/poseidon/poseidon.zok" as poseidon;
.
.
.
}
`
const hashProgram = `
${hashFragment}
.
.
.
`
const digProgram = `
${hashFragment}
.
.
.
`
Em seguida, temos a função de hash e dois programas Zokrates que vimos acima.
const digCompiled = zokrates.compile(digProgram)
const hashCompiled = zokrates.compile(hashProgram)
Aqui nós compilamos esses programas.
// Crie as chaves para verificação de conhecimento zero.
// Em um sistema de produção, você vai querer usar uma cerimônia de configuração.
// (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
Em um sistema de produção, poderíamos usar uma cerimônia de configuração (opens in a new tab) mais complicada, mas isso é bom o suficiente para uma demonstração. Não é um problema que os usuários possam conhecer a chave do provador - eles ainda não podem usá-la para provar coisas a menos que sejam verdadeiras. Como especificamos a entropia (o segundo parâmetro, ""), os resultados sempre serão os mesmos.
Nota: A compilação de programas Zokrates e a criação de chaves são processos lentos. Não há necessidade de repeti-los todas as vezes, apenas quando o tamanho do mapa muda. Em um sistema de produção, você os faria uma vez e depois armazenaria a saída. A única razão pela qual não estou fazendo isso aqui é por uma questão de simplicidade.
calculateMapHash
const calculateMapHash = function (hashMe: boolean[][]): string {
return (
"0x" +
BigInt(zokrates.computeWitness(hashCompiled, [hashMe]).output.slice(1, -1))
.toString(16)
.padStart(64, "0")
)
}
A função computeWitness (opens in a new tab) realmente executa o programa Zokrates. Ela retorna uma estrutura com dois campos: output, que é a saída do programa como uma string JSON, e witness, que é a informação necessária para criar uma prova de conhecimento zero do resultado. Aqui precisamos apenas da saída.
A saída é uma string no formato "31337", um número decimal entre aspas. Mas a saída que precisamos para viem é um número hexadecimal no formato 0x60A7. Então usamos .slice(1,-1) para remover as aspas e depois BigInt para passar a string restante, que é um número decimal, para um BigInt (opens in a new tab). .toString(16) converte este BigInt em uma string hexadecimal, e "0x"+ adiciona o marcador para números hexadecimais.
// Cave e retorne uma prova de conhecimento zero do resultado
// (código do lado do servidor)
A prova de conhecimento zero inclui as entradas públicas (x e y) e os resultados (hash do mapa e número de bombas).
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")
É um problema verificar se um índice está fora dos limites no Zokrates, então fazemos isso aqui.
const runResults = zokrates.computeWitness(digCompiled, [map, `${x}`, `${y}`])
Execute o programa de escavação.
const proof = zokrates.generateProof(
digCompiled.program,
runResults.witness,
proverKey)
return proof
}
Use generateProof (opens in a new tab) e retorne a prova.
const solidityVerifier = `
// Map size: ${width} x ${height}
\n${zokrates.exportSolidityVerifier(verifierKey)}
`
Um verificador em Solidity, um contrato inteligente que podemos implantar na blockchain e usar para verificar as provas geradas por digCompiled.program.
return {
zkDig,
calculateMapHash,
solidityVerifier,
}
}
Finalmente, retorne tudo o que outro código possa precisar.
Testes de segurança
Os testes de segurança são importantes porque um bug de funcionalidade acabará se revelando. Mas se o aplicativo for inseguro, é provável que isso permaneça oculto por muito tempo antes de ser revelado por alguém trapaceando e se apropriando de recursos que pertencem a outros.
Permissões
Há uma entidade privilegiada neste jogo, o servidor. É o único usuário com permissão para chamar as funções em ServerSystem (opens in a new tab). Podemos usar cast (opens in a new tab) para verificar se as chamadas para funções permissionadas são permitidas apenas como a conta do servidor.
A chave privada do servidor está em setupNetwork.ts (opens in a new tab).
-
No computador que executa
anvil(a blockchain), defina estas variáveis de ambiente.WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d -
Use
castpara tentar definir o endereço do verificador como um endereço não autorizado.cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $UNAUTHORIZED_KEYNão apenas
castrelata uma falha, mas você pode abrir o MUD Dev Tools no jogo no navegador, clicar em Tables e selecionar app__VerifierAddress. Veja que o endereço não é zero. -
Defina o endereço do verificador como o endereço do servidor.
cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $AUTHORIZED_KEYO endereço em app__VerifiedAddress agora deve ser zero.
Todas as funções do MUD no mesmo System passam pelo mesmo controle de acesso, então considero este teste suficiente. Se você não achar, pode verificar as outras funções em ServerSystem (opens in a new tab).
Abusos de conhecimento zero
A matemática para verificar o Zokrates está além do escopo deste tutorial (e das minhas habilidades). No entanto, podemos executar várias verificações no código de conhecimento zero para verificar se, caso não seja feito corretamente, ele falha. Todos esses testes exigirão que alteremos zero-knowledge.ts (opens in a new tab) e reiniciemos o aplicativo inteiro. Não é suficiente reiniciar o processo do servidor, porque isso coloca o aplicativo em um estado impossível (o jogador tem um jogo em andamento, mas o jogo não está mais disponível para o servidor).
Resposta errada
A possibilidade mais simples é fornecer a resposta errada na prova de conhecimento zero. Para fazer isso, entramos em zkDig e modificamos a linha 91 (opens in a new tab):
proof.inputs[3] = "0x" + "1".padStart(64, "0")
Isso significa que sempre afirmaremos que há uma bomba, independentemente da resposta correta. Tente jogar com esta versão e você verá na aba server da tela pnpm dev este erro:
cause: {
code: 3,
message: 'execution reverted: revert: Zero knowledge verification fail',
data: '0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000
000000000000000000000000000000000000000000000000205a65726f206b6e6f776c6564676520766572696669636174696f6
e206661696c'
},
Portanto, esse tipo de trapaça falha.
Prova errada
O que acontece se fornecermos as informações corretas, mas tivermos os dados de prova errados? Agora, substitua a linha 91 por:
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")],
}
Ainda falha, mas agora falha sem um motivo porque acontece durante a chamada do verificador.
Como um usuário pode verificar o código de confiança zero?
Contratos inteligentes são relativamente fáceis de verificar. Normalmente, o desenvolvedor publica o código-fonte em um explorador de blocos, e o explorador de blocos verifica se o código-fonte é compilado para o código na transação de implantação do contrato. No caso de Systems do MUD, isso é um pouco mais complicado (opens in a new tab), mas não muito.
Isso é mais difícil com conhecimento zero. O verificador inclui algumas constantes e executa alguns cálculos sobre elas. Isso não diz a você o que está sendo provado.
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)]);
A solução, pelo menos até que os exploradores de blocos consigam adicionar a verificação do Zokrates às suas interfaces de usuário, é que os desenvolvedores de aplicativos disponibilizem os programas Zokrates e que pelo menos alguns usuários os compilem eles mesmos com a chave de verificação apropriada.
Para fazer isso:
-
Crie um arquivo,
dig.zok, com o programa Zokrates. O código abaixo pressupõe que você manteve o tamanho original do mapa, 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); } // O número de minas na localização (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) } ); } -
Compile o código Zokrates e crie a chave de verificação. A chave de verificação deve ser criada com a mesma entropia usada no servidor original, neste caso, uma string vazia (opens in a new tab).
zokrates compile --input dig.zok zokrates setup -e "" -
Crie o verificador Solidity por conta própria e verifique se ele é funcionalmente idêntico ao da blockchain (o servidor adiciona um comentário, mas isso não é importante).
zokrates export-verifier diff verifier.sol ~/20240901-secret-state/packages/contracts/src/verifier.sol
Decisões de design
Em qualquer aplicativo suficientemente complexo, existem objetivos de design concorrentes que exigem concessões (trade-offs). Vamos analisar algumas dessas concessões e por que a solução atual é preferível a outras opções.
Por que conhecimento zero
Para o campo minado, você não precisa realmente de conhecimento zero. O servidor pode sempre manter o mapa e, em seguida, apenas revelá-lo por completo quando o jogo terminar. Então, no final do jogo, o contrato inteligente pode calcular o hash do mapa, verificar se ele corresponde e, se não corresponder, penalizar o servidor ou desconsiderar o jogo completamente.
Eu não usei essa solução mais simples porque ela só funciona para jogos curtos com um estado final bem definido. Quando um jogo é potencialmente infinito (como é o caso de mundos autônomos (opens in a new tab)), você precisa de uma solução que prove o estado sem revelá-lo.
Como um tutorial, este artigo precisava de um jogo curto e fácil de entender, mas essa técnica é mais útil para jogos mais longos.
Por que Zokrates?
Zokrates (opens in a new tab) não é a única biblioteca de conhecimento zero disponível, mas é semelhante a uma linguagem de programação imperativa (opens in a new tab) normal e suporta variáveis booleanas.
Para o seu aplicativo, com requisitos diferentes, você pode preferir usar Circum (opens in a new tab) ou Cairo (opens in a new tab).
Quando compilar o Zokrates
Neste programa, compilamos os programas Zokrates toda vez que o servidor é iniciado (opens in a new tab). Isso é claramente um desperdício de recursos, mas este é um tutorial, otimizado para simplicidade.
Se eu estivesse escrevendo um aplicativo em nível de produção, verificaria se tenho um arquivo com os programas Zokrates compilados para este tamanho de campo minado e, se sim, o usaria. O mesmo vale para implantar um contrato verificador onchain.
Criando as chaves do verificador e do provador
A criação de chaves (opens in a new tab) é outro cálculo puro que não precisa ser feito mais de uma vez para um determinado tamanho de campo minado. Novamente, isso é feito apenas uma vez por uma questão de simplicidade.
Além disso, poderíamos usar uma cerimônia de configuração (opens in a new tab). A vantagem de uma cerimônia de configuração é que você precisa da entropia ou de algum resultado intermediário de cada participante para trapacear na prova de conhecimento zero. Se pelo menos um participante da cerimônia for honesto e excluir essas informações, as provas de conhecimento zero estarão seguras contra certos ataques. No entanto, não há mecanismo para verificar se as informações foram excluídas de todos os lugares. Se as provas de conhecimento zero forem criticamente importantes, você vai querer participar da cerimônia de configuração.
Aqui, contamos com poderes perpétuos de tau (opens in a new tab), que teve dezenas de participantes. Provavelmente é seguro o suficiente e muito mais simples. Também não adicionamos entropia durante a criação da chave, o que torna mais fácil para os usuários verificarem a configuração de conhecimento zero.
Onde verificar
Podemos verificar as provas de conhecimento zero onchain (o que custa gás) ou no cliente (usando verify (opens in a new tab)). Eu escolhi a primeira opção, porque isso permite que você verifique o verificador uma vez e depois confie que ele não mudará enquanto o endereço do contrato para ele permanecer o mesmo. Se a verificação fosse feita no cliente, você teria que verificar o código que recebe toda vez que baixar o cliente.
Além disso, embora este jogo seja para um jogador, muitos jogos em blockchain são multijogador. A verificação onchain significa que você só verifica a prova de conhecimento zero uma vez. Fazer isso no cliente exigiria que cada cliente verificasse de forma independente.
Achatar o mapa no TypeScript ou no Zokrates?
Em geral, quando o processamento pode ser feito no TypeScript ou no Zokrates, é melhor fazê-lo no TypeScript, que é muito mais rápido e não requer provas de conhecimento zero. Esse é o motivo, por exemplo, de não fornecermos ao Zokrates o hash e fazê-lo verificar se está correto. A geração de hash tem que ser feita dentro do Zokrates, mas a correspondência entre o hash retornado e o hash onchain pode acontecer fora dele.
No entanto, ainda achatamos o mapa no Zokrates (opens in a new tab), enquanto poderíamos ter feito isso no TypeScript. O motivo é que as outras opções são, na minha opinião, piores.
-
Fornecer uma matriz unidimensional de booleanos para o código Zokrates e usar uma expressão como
x*(height+2) +ypara obter o mapa bidimensional. Isso tornaria o código (opens in a new tab) um pouco mais complicado, então decidi que o ganho de desempenho não vale a pena para um tutorial. -
Enviar ao Zokrates tanto a matriz unidimensional quanto a matriz bidimensional. No entanto, essa solução não nos traz nenhum ganho. O código Zokrates teria que verificar se a matriz unidimensional fornecida é realmente a representação correta da matriz bidimensional. Portanto, não haveria nenhum ganho de desempenho.
-
Achatar a matriz bidimensional no Zokrates. Esta é a opção mais simples, então eu a escolhi.
Onde armazenar mapas
Neste aplicativo, gamesInProgress (opens in a new tab) é simplesmente uma variável na memória. Isso significa que, se o seu servidor morrer e precisar ser reiniciado, todas as informações armazenadas serão perdidas. Os jogadores não apenas não conseguem continuar o jogo, como também não podem iniciar um novo jogo porque o componente onchain acha que eles ainda têm um jogo em andamento.
Isso é claramente um design ruim para um sistema de produção, no qual você armazenaria essas informações em um banco de dados. O único motivo pelo qual usei uma variável aqui é porque este é um tutorial e a simplicidade é a principal consideração.
Conclusão: Sob quais condições esta é a técnica apropriada?
Então, agora você sabe como escrever um jogo com um servidor que armazena um estado secreto que não pertence onchain. Mas em quais casos você deve fazer isso? Há duas considerações principais.
-
Jogo de longa duração: Como mencionado acima, em um jogo curto você pode simplesmente publicar o estado assim que o jogo terminar e ter tudo verificado nesse momento. Mas essa não é uma opção quando o jogo leva um tempo longo ou indefinido, e o estado precisa permanecer secreto.
-
Alguma centralização aceitável: Provas de conhecimento zero podem verificar a integridade, garantindo que uma entidade não está falsificando os resultados. O que elas não podem fazer é garantir que a entidade ainda estará disponível e responderá a mensagens. Em situações onde a disponibilidade também precisa ser descentralizada, provas de conhecimento zero não são uma solução suficiente, e você precisa de computação multipartidária (opens in a new tab).
Veja aqui mais do meu trabalho (opens in a new tab).
Agradecimentos
- Alvaro Alonso leu um rascunho deste artigo e esclareceu alguns dos meus mal-entendidos sobre o Zokrates.
Quaisquer erros remanescentes são de minha responsabilidade.
