Escreva um plasma específico de aplicativo que preserva a privacidade
Introdução
Em contraste com rollups, plasmas usam a mainnet do Ethereum para integridade, mas não para disponibilidade. Neste artigo, escrevemos um aplicativo que se comporta como um plasma, com o Ethereum garantindo integridade (sem alterações não autorizadas), mas não disponibilidade (um componente centralizado pode cair e desativar todo o sistema).
O aplicativo que escrevemos aqui é um banco que preserva a privacidade. Diferentes endereços têm contas com saldos, e eles podem enviar dinheiro (ETH) para outras contas. O banco publica hashes do estado (contas e seus saldos) e transações, mas mantém os saldos reais fora da cadeia, onde eles podem permanecer privados.
Design
Este não é um sistema pronto para produção, mas uma ferramenta de ensino. Como tal, é escrito com várias suposições simplificadoras.
-
Conjunto de contas fixo. Há um número específico de contas, e cada conta pertence a um endereço predeterminado. Isso torna o sistema muito mais simples, porque é difícil lidar com estruturas de dados de tamanho variável em provas de conhecimento zero. Para um sistema pronto para produção, podemos usar a raiz de Merkle como o hash de estado e fornecer provas de Merkle para os saldos necessários.
-
Armazenamento de memória. Em um sistema de produção, precisamos escrever todos os saldos das contas no disco para preservá-los em caso de reinicialização. Aqui, não há problema se a informação for simplesmente perdida.
-
Apenas transferências. Um sistema de produção exigiria uma maneira de depositar ativos no banco e retirá-los. Mas o objetivo aqui é apenas ilustrar o conceito, então este banco está limitado a transferências.
Provas de conhecimento zero
Em um nível fundamental, uma prova de conhecimento zero mostra que o provador conhece alguns dados, Dadosprivados de tal forma que existe uma relação Relação entre alguns dados públicos, Dadospúblicos, e Dadosprivados. O verificador conhece a Relação e os Dadospúblicos.
Para preservar a privacidade, precisamos que os estados e as transações sejam privados. Mas para garantir a integridade, precisamos que o hash criptográfico (opens in a new tab) dos estados seja público. Para provar às pessoas que enviam transações que essas transações realmente aconteceram, também precisamos publicar os hashes das transações.
Na maioria dos casos, Dadosprivados são a entrada para o programa de prova de conhecimento zero, e Dadospúblicos são a saída.
Estes campos em Dadosprivados:
- Estadon, o estado antigo
- Estadon+1, o novo estado
- Transação, uma transação que muda do estado antigo para o novo. Essa transação precisa incluir estes campos:
- Endereço de destino que recebe a transferência
- Valor sendo transferido
- Nonce para garantir que cada transação possa ser processada apenas uma vez. O endereço de origem não precisa estar na transação, porque pode ser recuperado da assinatura.
- Assinatura, uma assinatura que está autorizada a realizar a transação. No nosso caso, o único endereço autorizado a realizar uma transação é o endereço de origem. Como nosso sistema de conhecimento zero funciona da maneira que funciona, também precisamos da chave pública da conta, além da assinatura do Ethereum.
Estes são os campos em Dadospúblicos:
- Hash(Estadon) o hash do estado antigo
- Hash(Estadon+1) o hash do novo estado
- Hash(Transação) o hash da transação que muda o estado de Estadon para Estadon+1.
A relação verifica várias condições:
- Os hashes públicos são de fato os hashes corretos para os campos privados.
- A transação, quando aplicada ao estado antigo, resulta no novo estado.
- A assinatura vem do endereço de origem da transação.
Devido às propriedades das funções de hash criptográficas, provar essas condições é suficiente para garantir a integridade.
Estruturas de dados
A estrutura de dados primária é o estado mantido pelo servidor. Para cada conta, o servidor rastreia o saldo da conta e um nonce (opens in a new tab), usado para prevenir ataques de repetição (opens in a new tab).
Componentes
Este sistema requer dois componentes:
- O servidor que recebe transações, as processa e publica os hashes na cadeia juntamente com as provas de conhecimento zero.
- Um contrato inteligente que armazena os hashes e verifica as provas de conhecimento zero para garantir que as transições de estado sejam legítimas.
Fluxo de dados e controle
Estas são as maneiras como os vários componentes se comunicam para transferir de uma conta para outra.
-
Um navegador da web envia uma transação assinada solicitando uma transferência da conta do signatário para uma conta diferente.
-
O servidor verifica se a transação é válida:
- O signatário tem uma conta no banco com saldo suficiente.
- O destinatário tem uma conta no banco.
-
O servidor calcula o novo estado subtraindo o valor transferido do saldo do signatário e adicionando-o ao saldo do destinatário.
-
O servidor calcula uma prova de conhecimento zero de que a mudança de estado é válida.
-
O servidor envia ao Ethereum uma transação que inclui:
- O novo hash de estado
- O hash da transação (para que o remetente da transação saiba que foi processada)
- A prova de conhecimento zero que comprova que a transição para o novo estado é válida
-
O contrato inteligente verifica a prova de conhecimento zero.
-
Se a prova de conhecimento zero for verificada, o contrato inteligente executa estas ações:
- Atualiza o hash do estado atual para o novo hash de estado
- Emite uma entrada de log com o novo hash de estado e o hash da transação
Ferramentas
Para o código do lado do cliente, vamos usar Vite (opens in a new tab), React (opens in a new tab), Viem (opens in a new tab) e Wagmi (opens in a new tab). Estas são ferramentas padrão da indústria; se você não estiver familiarizado com elas, pode usar este tutorial.
A maior parte do servidor é escrita em JavaScript usando Node (opens in a new tab). A parte de conhecimento zero é escrita em Noir (opens in a new tab). Precisamos da versão 1.0.0-beta.10, então depois de instalar o Noir conforme as instruções (opens in a new tab), execute:
1noirup -v 1.0.0-beta.10A blockchain que usamos é a anvil, uma blockchain de teste local que faz parte da Foundry (opens in a new tab).
Implementação
Como este é um sistema complexo, vamos implementá-lo em etapas.
Etapa 1 - Conhecimento zero manual
Na primeira etapa, assinaremos uma transação no navegador e, em seguida, forneceremos manualmente as informações para a prova de conhecimento zero. O código de conhecimento zero espera obter essa informação em server/noir/Prover.toml (documentado aqui (opens in a new tab)).
Para ver em ação:
-
Certifique-se de ter o Node (opens in a new tab) e o Noir (opens in a new tab) instalados. De preferência, instale-os em um sistema UNIX como macOS, Linux ou WSL (opens in a new tab).
-
Baixe o código da etapa 1 e inicie o servidor da web para servir o código do cliente.
1git clone https://github.com/qbzzt/250911-zk-bank.git -b 01-manual-zk2cd 250911-zk-bank3cd client4npm install5npm run devA razão pela qual você precisa de um servidor web aqui é que, para evitar certos tipos de fraude, muitas carteiras (como a MetaMask) não aceitam arquivos servidos diretamente do disco
-
Abra um navegador com uma carteira.
-
Na carteira, insira uma nova frase secreta. Observe que isso excluirá sua frase secreta existente, então certifique-se de ter um backup.
A frase secreta é
test test test test test test test test test test test junk, a frase secreta de teste padrão para o anvil. -
Conecte-se à carteira e selecione sua conta de destino e o valor.
-
Clique em Assinar e assine a transação.
-
Sob o título Prover.toml, você encontrará um texto. Substitua
server/noir/Prover.tomlpor esse texto. -
Execute a prova de conhecimento zero.
1cd ../server/noir2nargo executeA saída deve ser semelhante a
1ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute23[zkBank] Circuit witness successfully solved4[zkBank] Witness saved to target/zkBank.gz5[zkBank] Circuit output: (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85) -
Compare os dois últimos valores com o hash que você vê no navegador da web para ver se a mensagem foi transformada em hash corretamente.
server/noir/Prover.toml
Este arquivo (opens in a new tab) mostra o formato de informação esperado pelo Noir.
1message="send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 500 finney (milliEth) 0 "A mensagem está em formato de texto, o que facilita o entendimento do usuário (o que é necessário ao assinar) e a análise pelo código Noir. O valor é cotado em finneys para permitir transferências fracionárias, por um lado, e ser facilmente legível, por outro. O último número é o nonce (opens in a new tab).
A string tem 100 caracteres de comprimento. As provas de conhecimento zero não lidam bem com dados de tamanho variável, por isso muitas vezes é necessário preencher os dados.
1pubKeyX=["0x83",...,"0x75"]2pubKeyY=["0x35",...,"0xa5"]3signature=["0xb1",...,"0x0d"]Esses três parâmetros são matrizes de bytes de tamanho fixo.
1[[accounts]]2address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"3balance=100_0004nonce=056[[accounts]]7address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"8balance=100_0009nonce=0Exibir tudoEsta é a maneira de especificar uma matriz de estruturas. Para cada entrada, especificamos o endereço, o saldo (em milliETH, também conhecido como finney (opens in a new tab)), e o próximo valor do nonce.
client/src/Transfer.tsx
Este arquivo (opens in a new tab) implementa o processamento do lado do cliente e gera o arquivo server/noir/Prover.toml (aquele que inclui os parâmetros de conhecimento zero).
Aqui está a explicação das partes mais interessantes.
1export default attrs => {Esta função cria o componente React Transfer, que outros arquivos podem importar.
1 const accounts = [2 "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",3 "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",4 "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",5 "0x90F79bf6EB2c4f870365E785982E1f101E93b906",6 "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",7 ]Estes são os endereços das contas, os endereços criados pelo test ... frase secreta test junk. Se você quiser usar seus próprios endereços, basta modificar esta definição.
1 const account = useAccount()2 const wallet = createWalletClient({3 transport: custom(window.ethereum!)4 })Estes hooks da Wagmi (opens in a new tab) nos permitem acessar a biblioteca viem (opens in a new tab) e a carteira.
1 const message = `send ${toAccount} ${ethAmount*1000} finney (milliEth) ${nonce}`.padEnd(100, " ")Esta é a mensagem, preenchida com espaços. Toda vez que uma das variáveis useState (opens in a new tab) muda, o componente é redesenhado e a message é atualizada.
1 const sign = async () => {Esta função é chamada quando o usuário clica no botão Assinar. A mensagem é atualizada automaticamente, mas a assinatura requer a aprovação do usuário na carteira, e não queremos pedi-la a menos que seja necessário.
1 const signature = await wallet.signMessage({2 account: fromAccount,3 message,4 })Peça à carteira para assinar a mensagem (opens in a new tab).
1 const hash = hashMessage(message)Obtenha o hash da mensagem. É útil fornecê-lo ao usuário para depuração (do código Noir).
1 const pubKey = await recoverPublicKey({2 hash,3 signature4 })Obtenha a chave pública (opens in a new tab). Isso é necessário para a função ecrecover do Noir (opens in a new tab).
1 setSignature(signature)2 setHash(hash)3 setPubKey(pubKey)Defina as variáveis de estado. Fazer isso redesenha o componente (após a saída da função sign) e mostra ao usuário os valores atualizados.
1 let proverToml = `O texto para Prover.toml.
1message="${message}"23pubKeyX=${hexToArray(pubKey.slice(4,4+2*32))}4pubKeyY=${hexToArray(pubKey.slice(4+2*32))}Viem nos fornece a chave pública como uma string hexadecimal de 65 bytes. O primeiro byte é 0x04, um marcador de versão. Isso é seguido por 32 bytes para o x da chave pública e, em seguida, 32 bytes para o y da chave pública.
No entanto, o Noir espera obter essa informação como duas matrizes de bytes, uma para x e outra para y. É mais fácil analisá-lo aqui no cliente do que como parte da prova de conhecimento zero.
Observe que esta é uma boa prática em conhecimento zero em geral. O código dentro de uma prova de conhecimento zero é caro, então qualquer processamento que possa ser feito fora da prova de conhecimento zero deve ser feito fora da prova de conhecimento zero.
1signature=${hexToArray(signature.slice(2,-2))}A assinatura também é fornecida como uma string hexadecimal de 65 bytes. No entanto, o último byte só é necessário para recuperar a chave pública. Como a chave pública já será fornecida ao código Noir, não precisamos dela para verificar a assinatura, e o código Noir não a exige.
1${accounts.map(accountInProverToml).reduce((a,b) => a+b, "")}2`Forneça as contas.
1 setProverToml(proverToml)2 }34 return (5 <>6 <h2>Transferência</h2>Este é o formato HTML (mais precisamente, JSX (opens in a new tab)) do componente.
server/noir/src/main.nr
Este arquivo (opens in a new tab) é o código de conhecimento zero real.
1use std::hash::pedersen_hash;O hash de Pedersen (opens in a new tab) é fornecido com a biblioteca padrão do Noir (opens in a new tab). As provas de conhecimento zero comumente usam esta função de hash. É muito mais fácil de calcular dentro de circuitos aritméticos (opens in a new tab) em comparação com as funções de hash padrão.
1use keccak256::keccak256;2use dep::ecrecover;Estas duas funções são bibliotecas externas, definidas em Nargo.toml (opens in a new tab). Elas são precisamente o que seus nomes indicam, uma função que calcula o hash keccak256 (opens in a new tab) e uma função que verifica assinaturas do Ethereum e recupera o endereço Ethereum do signatário.
1global ACCOUNT_NUMBER : u32 = 5;Noir é inspirado em Rust (opens in a new tab). As variáveis, por padrão, são constantes. É assim que definimos constantes de configuração globais. Especificamente, ACCOUNT_NUMBER é o número de contas que armazenamos.
Tipos de dados nomeados u<número> são esse número de bits, sem sinal. Os únicos tipos suportados são u8, u16, u32, u64 e u128.
1global FLAT_ACCOUNT_FIELDS : u32 = 2;Essa variável é usada para o hash de Pedersen das contas, como explicado abaixo.
1global MESSAGE_LENGTH : u32 = 100;Como explicado acima, o comprimento da mensagem é fixo. Ele é especificado aqui.
1global ASCII_MESSAGE_LENGTH : [u8; 3] = [0x31, 0x30, 0x30];2global HASH_BUFFER_SIZE : u32 = 26+3+MESSAGE_LENGTH;As assinaturas EIP-191 (opens in a new tab) requerem um buffer com um prefixo de 26 bytes, seguido pelo comprimento da mensagem em ASCII e, finalmente, a própria mensagem.
1struct Account {2 balance: u128,3 address: Field,4 nonce: u32,5}A informação que armazenamos sobre uma conta. Field (opens in a new tab) é um número, normalmente até 253 bits, que pode ser usado diretamente no circuito aritmético (opens in a new tab) que implementa a prova de conhecimento zero. Aqui usamos o Field para armazenar um endereço Ethereum de 160 bits.
1struct TransferTxn {2 from: Field,3 to: Field,4 amount: u128,5 nonce: u326}A informação que armazenamos para uma transação de transferência.
1fn flatten_account(account: Account) -> [Field; FLAT_ACCOUNT_FIELDS] {Uma definição de função. O parâmetro é a informação da Conta. O resultado é uma matriz de variáveis Field, cujo comprimento é FLAT_ACCOUNT_FIELDS
1 let flat = [2 account.address,3 ((account.balance << 32) + account.nonce.into()).into(),4 ];O primeiro valor na matriz é o endereço da conta. O segundo inclui tanto o saldo quanto o nonce. As chamadas .into() mudam um número para o tipo de dados que ele precisa ser. account.nonce é um valor u32, mas para adicioná-lo a account.balance << 32, um valor u128, ele precisa ser um u128. Esse é o primeiro .into(). O segundo converte o resultado u128 em um Field para que ele se encaixe na matriz.
1 flat2}No Noir, as funções só podem retornar um valor no final (não há retorno antecipado). Para especificar o valor de retorno, você o avalia pouco antes do colchete de fechamento da função.
1fn flatten_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] {Esta função transforma a matriz de contas em uma matriz Field, que pode ser usada como entrada para um Hash de Petersen.
1 let mut flat: [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] = [0; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER];Esta é a forma de especificar uma variável mutável, ou seja, não uma constante. As variáveis no Noir devem sempre ter um valor, então inicializamos esta variável com todos os zeros.
1 for i in 0..ACCOUNT_NUMBER {Este é um loop for. Note que os limites são constantes. Os loops do Noir precisam ter seus limites conhecidos em tempo de compilação. A razão é que os circuitos aritméticos não suportam o controle de fluxo. Ao processar um loop for, o compilador simplesmente coloca o código dentro dele várias vezes, uma para cada iteração.
1 let fields = flatten_account(accounts[i]);2 for j in 0..FLAT_ACCOUNT_FIELDS {3 flat[i*FLAT_ACCOUNT_FIELDS + j] = fields[j];4 }5 }67 flat8}910fn hash_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> Field {11 pedersen_hash(flatten_accounts(accounts))12}Exibir tudoFinalmente, chegamos à função que gera o hash da matriz de contas.
1fn find_account(accounts: [Account; ACCOUNT_NUMBER], address: Field) -> u32 {2 let mut account : u32 = ACCOUNT_NUMBER;34 for i in 0..ACCOUNT_NUMBER {5 if accounts[i].address == address {6 account = i;7 }8 }Essa função encontra a conta com um endereço específico. Esta função seria terrivelmente ineficiente em código padrão porque itera sobre todas as contas, mesmo depois de ter encontrado o endereço.
No entanto, em provas de conhecimento zero, não há controle de fluxo. Se precisarmos verificar uma condição, teremos que verificá-la todas as vezes.
Algo semelhante acontece com as instruções if. A instrução if no loop acima é traduzida para estas declarações matemáticas.
resultadocondição = contas[i].endereço == endereço // um se forem iguais, zero caso contrário
contanova = resultadocondição*i + (1-resultadocondição)*contaantiga
1 assert (account < ACCOUNT_NUMBER, f"{address} does not have an account");23 account4}A função assert (opens in a new tab) faz com que a prova de conhecimento zero falhe se a asserção for falsa. Neste caso, se não conseguirmos encontrar uma conta com o endereço relevante. Para relatar o endereço, usamos uma string de formatação (opens in a new tab).
1fn apply_transfer_txn(accounts: [Account; ACCOUNT_NUMBER], txn: TransferTxn) -> [Account; ACCOUNT_NUMBER] {Esta função aplica uma transação de transferência e retorna a nova matriz de contas.
1 let from = find_account(accounts, txn.from);2 let to = find_account(accounts, txn.to);34 let (txnFrom, txnAmount, txnNonce, accountNonce) =5 (txn.from, txn.amount, txn.nonce, accounts[from].nonce);Não podemos acessar elementos da estrutura dentro de uma string de formato no Noir, então criamos uma cópia utilizável.
1 assert (accounts[from].balance >= txn.amount,2 f"{txnFrom} does not have {txnAmount} finney");34 assert (accounts[from].nonce == txn.nonce,5 f"Transaction has nonce {txnNonce}, but the account is expected to use {accountNonce}");Estas são duas condições que podem tornar uma transação inválida.
1 let mut newAccounts = accounts;23 newAccounts[from].balance -= txn.amount;4 newAccounts[from].nonce += 1;5 newAccounts[to].balance += txn.amount;67 newAccounts8}Crie a nova matriz de contas e, em seguida, retorne-a.
1fn readAddress(messageBytes: [u8; MESSAGE_LENGTH]) -> FieldEsta função lê o endereço da mensagem.
1{2 let mut result : Field = 0;34 for i in 7..47 {O endereço tem sempre 20 bytes (ou seja, 40 dígitos hexadecimais) de comprimento e começa no caractere #7.
1 result *= 0x10;2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-93 result += (messageBytes[i]-48).into();4 }5 if messageBytes[i] >= 65 & messageBytes[i] <= 70 { // A-F6 result += (messageBytes[i]-65+10).into()7 }8 if messageBytes[i] >= 97 & messageBytes[i] <= 102 { // a-f9 result += (messageBytes[i]-97+10).into()10 } 11 } 1213 result14}1516fn readAmountAndNonce(messageBytes: [u8; MESSAGE_LENGTH]) -> (u128, u32)Exibir tudoLeia o valor e o nonce da mensagem.
1{2 let mut amount : u128 = 0;3 let mut nonce: u32 = 0;4 let mut stillReadingAmount: bool = true;5 let mut lookingForNonce: bool = false;6 let mut stillReadingNonce: bool = false;Na mensagem, o primeiro número após o endereço é a quantidade de finney (também conhecido como milésimo de um ETH) a ser transferido. O segundo número é o nonce. Qualquer texto entre eles é ignorado.
1 for i in 48..MESSAGE_LENGTH {2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-93 let digit = (messageBytes[i]-48);45 if stillReadingAmount {6 amount = amount*10 + digit.into();7 }89 if lookingForNonce { // We just found it10 stillReadingNonce = true;11 lookingForNonce = false;12 }1314 if stillReadingNonce {15 nonce = nonce*10 + digit.into();16 }17 } else {18 if stillReadingAmount {19 stillReadingAmount = false;20 lookingForNonce = true;21 }22 if stillReadingNonce {23 stillReadingNonce = false;24 }25 }26 }2728 (amount, nonce)29}Exibir tudoRetornar uma tupla (opens in a new tab) é a maneira Noir de retornar múltiplos valores de uma função.
1fn readTransferTxn(message: str<MESSAGE_LENGTH>) -> TransferTxn 2{3 let mut txn: TransferTxn = TransferTxn { from: 0, to: 0, amount:0, nonce:0 };4 let messageBytes = message.as_bytes();56 txn.to = readAddress(messageBytes);7 let (amount, nonce) = readAmountAndNonce(messageBytes);8 txn.amount = amount;9 txn.nonce = nonce;1011 txn12}Exibir tudoEsta função converte a mensagem em bytes e, em seguida, converte os valores em um TransferTxn.
1// O equivalente a hashMessage do Viem2// https://viem.sh/docs/utilities/hashMessage#hashmessage3fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {Conseguimos usar o Hash de Pedersen para as contas porque elas só são hasheadas dentro da prova de conhecimento zero. No entanto, neste código, precisamos verificar a assinatura da mensagem, que é gerada pelo navegador. Para isso, precisamos seguir o formato de assinatura do Ethereum no EIP 191 (opens in a new tab). Isso significa que precisamos criar um buffer combinado com um prefixo padrão, o comprimento da mensagem em ASCII e a própria mensagem, e usar o keccak256 padrão do Ethereum para gerar o hash.
1 // ASCII prefix2 let prefix_bytes = [3 0x19, // \x194 0x45, // 'E'5 0x74, // 't'6 0x68, // 'h'7 0x65, // 'e'8 0x72, // 'r'9 0x65, // 'e'10 0x75, // 'u'11 0x6D, // 'm'12 0x20, // ' '13 0x53, // 'S'14 0x69, // 'i'15 0x67, // 'g'16 0x6E, // 'n'17 0x65, // 'e'18 0x64, // 'd'19 0x20, // ' '20 0x4D, // 'M'21 0x65, // 'e'22 0x73, // 's'23 0x73, // 's'24 0x61, // 'a'25 0x67, // 'g'26 0x65, // 'e'27 0x3A, // ':'28 0x0A // '\n'29 ];Exibir tudoPara evitar casos em que um aplicativo peça ao usuário para assinar uma mensagem que possa ser usada como uma transação ou para algum outro propósito, o EIP 191 especifica que todas as mensagens assinadas comecem com o caractere 0x19 (não é um caractere ASCII válido) seguido por Ethereum Signed Message: e uma nova linha.
1 let mut buffer: [u8; HASH_BUFFER_SIZE] = [0u8; HASH_BUFFER_SIZE];2 for i in 0..26 {3 buffer[i] = prefix_bytes[i];4 }56 let messageBytes : [u8; MESSAGE_LENGTH] = message.as_bytes();78 if MESSAGE_LENGTH <= 9 {9 for i in 0..1 {10 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];11 }1213 for i in 0..MESSAGE_LENGTH {14 buffer[i+26+1] = messageBytes[i];15 }16 }1718 if MESSAGE_LENGTH >= 10 & MESSAGE_LENGTH <= 99 {19 for i in 0..2 {20 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];21 }2223 for i in 0..MESSAGE_LENGTH {24 buffer[i+26+2] = messageBytes[i];25 }26 }2728 if MESSAGE_LENGTH >= 100 {29 for i in 0..3 {30 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];31 }3233 for i in 0..MESSAGE_LENGTH {34 buffer[i+26+3] = messageBytes[i];35 }36 }3738 assert(MESSAGE_LENGTH < 1000, "Messages whose length is over three digits are not supported");Exibir tudoLida com comprimentos de mensagem de até 999 e falha se for maior. Adicionei este código, embora o comprimento da mensagem seja uma constante, porque torna mais fácil alterá-lo. Em um sistema de produção, você provavelmente apenas assumiria que MESSAGE_LENGTH não muda para obter um melhor desempenho.
1 keccak256::keccak256(buffer, HASH_BUFFER_SIZE)2}Use a função keccak256 padrão do Ethereum.
1fn signatureToAddressAndHash(2 message: str<MESSAGE_LENGTH>, 3 pubKeyX: [u8; 32],4 pubKeyY: [u8; 32],5 signature: [u8; 64]6 ) -> (Field, Field, Field) // address, first 16 bytes of hash, last 16 bytes of hash 7{Esta função verifica a assinatura, o que requer o hash da mensagem. Em seguida, nos fornece o endereço que a assinou e o hash da mensagem. O hash da mensagem é fornecido em dois valores de Field porque eles são mais fáceis de usar no resto do programa do que uma matriz de bytes.
Precisamos usar dois valores Field porque os cálculos de campo são feitos módulo (opens in a new tab) um número grande, mas esse número é tipicamente menor que 256 bits (caso contrário, seria difícil realizar esses cálculos no EVM).
1 let hash = hashMessage(message);23 let mut (hash1, hash2) = (0,0);45 for i in 0..16 {6 hash1 = hash1*256 + hash[31-i].into();7 hash2 = hash2*256 + hash[15-i].into();8 }Especifique hash1 e hash2 como variáveis mutáveis e escreva o hash nelas byte a byte.
1 (2 ecrecover::ecrecover(pubKeyX, pubKeyY, signature, hash), Isto é semelhante ao ecrecover do Solidity (opens in a new tab), com duas diferenças importantes:
- Se a assinatura não for válida, a chamada falha em um
asserte o programa é abortado. - Embora a chave pública possa ser recuperada da assinatura e do hash, este é um processamento que pode ser feito externamente e, portanto, não vale a pena fazer dentro da prova de conhecimento zero. Se alguém tentar nos enganar aqui, a verificação da assinatura falhará.
1 hash1,2 hash23 )4}56fn main(7 accounts: [Account; ACCOUNT_NUMBER],8 message: str<MESSAGE_LENGTH>,9 pubKeyX: [u8; 32],10 pubKeyY: [u8; 32],11 signature: [u8; 64],12 ) -> pub (13 Field, // Hash of old accounts array14 Field, // Hash of new accounts array15 Field, // First 16 bytes of message hash16 Field, // Last 16 bytes of message hash17 )Exibir tudoFinalmente, chegamos à função main. Precisamos provar que temos uma transação que altera validamente o hash das contas do valor antigo para o novo. Também precisamos provar que tem este hash de transação específico para que a pessoa que a enviou saiba que a sua transação foi processada.
1{2 let mut txn = readTransferTxn(message);Precisamos que txn seja mutável porque não lemos o endereço de origem da mensagem, nós o lemos da assinatura.
1 let (fromAddress, txnHash1, txnHash2) = signatureToAddressAndHash(2 message,3 pubKeyX,4 pubKeyY,5 signature);67 txn.from = fromAddress;89 let newAccounts = apply_transfer_txn(accounts, txn);1011 (12 hash_accounts(accounts),13 hash_accounts(newAccounts),14 txnHash1,15 txnHash216 )17}Exibir tudoEstágio 2 - Adicionando um servidor
No segundo estágio, adicionamos um servidor que recebe e implementa transações de transferência do navegador.
Para ver em ação:
-
Pare o Vite se ele estiver em execução.
-
Baixe o branch que inclui o servidor e certifique-se de que você tem todos os módulos necessários.
1git checkout 02-add-server2cd client3npm install4cd ../server5npm installNão há necessidade de compilar o código Noir, é o mesmo código que você usou na etapa 1.
-
Inicie o servidor.
1npm run start -
Em uma janela de linha de comando separada, execute o Vite para servir o código do navegador.
1cd client2npm run dev -
Acesse o código do cliente em http://localhost:5173 (opens in a new tab)
-
Antes de emitir uma transação, você precisa saber o nonce, bem como o valor que pode enviar. Para obter esta informação, clique em Atualizar dados da conta e assine a mensagem.
Temos um dilema aqui. Por um lado, não queremos assinar uma mensagem que pode ser reutilizada (um ataque de repetição (opens in a new tab)), e é por isso que queremos um nonce em primeiro lugar. No entanto, ainda não temos um nonce. A solução é escolher um nonce que possa ser usado apenas uma vez e que já tenhamos em ambos os lados, como a hora atual.
O problema com esta solução é que o tempo pode não estar perfeitamente sincronizado. Então, em vez disso, assinamos um valor que muda a cada minuto. Isso significa que nossa janela de vulnerabilidade a ataques de repetição é de no máximo um minuto. Considerando que em produção a solicitação assinada será protegida por TLS, e que o outro lado do túnel - o servidor - já pode divulgar o saldo e o nonce (ele precisa conhecê-los para funcionar), este é um risco aceitável.
-
Uma vez que o navegador recebe de volta o saldo e o nonce, ele mostra o formulário de transferência. Selecione o endereço de destino e o valor e clique em Transferir. Assine este pedido.
-
Para ver a transferência, Atualize os dados da conta ou olhe na janela onde você executa o servidor. O servidor registra o estado sempre que ele muda.
1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start23> server@1.0.0 start4> node --experimental-json-modules index.mjs56Listening on port 30007Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 processed8New state:90xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 64000 (1)100x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 100000 (0)110x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)120x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)130x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)14Txn send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 7200 finney (milliEth) 1 processed15New state:160xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 56800 (2)170x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)180x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)190x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)200x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)21Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 3000 finney (milliEth) 2 processed22New state:230xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 53800 (3)240x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)250x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)260x90F79bf6EB2c4f870365E785982E1f101E93b906 has 139000 (0)270x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)Exibir tudo
server/index.mjs
Este arquivo (opens in a new tab) contém o processo do servidor e interage com o código Noir em main.nr (opens in a new tab). Aqui está uma explicação das partes interessantes.
1import { Noir } from '@noir-lang/noir_js'A biblioteca noir.js (opens in a new tab) faz a interface entre o código JavaScript e o código Noir.
1const circuit = JSON.parse(await fs.readFile("./noir/target/zkBank.json"))2const noir = new Noir(circuit)Carregue o circuito aritmético - o programa Noir compilado que criamos na etapa anterior - e prepare-se para executá-lo.
1// Só fornecemos informações da conta em resposta a uma solicitação assinada2const accountInformation = async signature => {3 const fromAddress = await recoverAddress({4 hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),5 signature6 })Para fornecer informações da conta, só precisamos da assinatura. A razão é que já sabemos qual será a mensagem e, portanto, o hash da mensagem.
1const processMessage = async (message, signature) => {Processe uma mensagem e execute a transação que ela codifica.
1 // Obtenha a chave pública2 const pubKey = await recoverPublicKey({3 hash,4 signature5 })Agora que executamos o JavaScript no servidor, podemos recuperar a chave pública lá, em vez de no cliente.
1 let noirResult2 try {3 noirResult = await noir.execute({4 message,5 signature: signature.slice(2,-2).match(/.{2}/g).map(x => `0x${x}`),6 pubKeyX,7 pubKeyY,8 accounts: Accounts9 })Exibir tudonoir.execute executa o programa Noir. Os parâmetros são equivalentes aos fornecidos em Prover.toml (opens in a new tab). Note que valores longos são fornecidos como um array de strings hexadecimais (["0x60", "0xA7"]), e não como um valor hexadecimal único (0x60A7), como faz o Viem.
1 } catch (err) {2 console.log(`Noir error: ${err}`)3 throw Error("Invalid transaction, not processed")4 }Se houver um erro, capture-o e, em seguida, retransmita uma versão simplificada para o cliente.
1 Accounts[fromAccountNumber].nonce++2 Accounts[fromAccountNumber].balance -= amount3 Accounts[toAccountNumber].balance += amountAplique a transação. Já fizemos isso no código Noir, mas é mais fácil fazer de novo aqui do que extrair o resultado de lá.
1let Accounts = [2 {3 address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",4 balance: 5000,5 nonce: 0,6 },A estrutura Contas inicial.
Estágio 3 - Contratos inteligentes Ethereum
-
Pare os processos do servidor e do cliente.
-
Baixe o branch com os contratos inteligentes e certifique-se de que você tem todos os módulos necessários.
1git checkout 03-smart-contracts2cd client3npm install4cd ../server5npm install -
Execute
anvilem uma janela de linha de comando separada. -
Gere a chave de verificação e o verificador solidity, em seguida, copie o código do verificador para o projeto Solidity.
1cd noir2bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak3bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol4cp target/Verifier.sol ../../smart-contracts/src -
Vá para os contratos inteligentes e defina as variáveis de ambiente para usar a blockchain
anvil.1cd ../../smart-contracts2export ETH_RPC_URL=http://localhost:85453ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -
Implante
Verifier.sole armazene o endereço em uma variável de ambiente.1VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'`2echo $VERIFIER_ADDRESS -
Implante o contrato
ZkBank.1ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'`2echo $ZKBANK_ADDRESSO valor
0x199..67bé o hash Pederson do estado inicial deContas. Se você modificar este estado inicial emserver/index.mjs, você pode executar uma transação para ver o hash inicial relatado pela prova de conhecimento zero. -
Execute o servidor.
1cd ../server2npm run start -
Execute o cliente em uma janela de linha de comando diferente.
1cd client2npm run dev -
Execute algumas transações.
-
Para verificar se o estado mudou na cadeia, reinicie o processo do servidor. Veja que o
ZkBanknão aceita mais transações, porque o valor original do hash nas transações difere do valor do hash armazenado na cadeia.Este é o tipo de erro esperado.
1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start23> server@1.0.0 start4> node --experimental-json-modules index.mjs56Listening on port 30007Verification error: ContractFunctionExecutionError: The contract function "processTransaction" reverted with the following reason:8Wrong old state hash910Contract Call:11 address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F051212 function: processTransaction(bytes _proof, bytes32[] _publicInputs)13 args: (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000Exibir tudo
server/index.mjs
As mudanças neste arquivo se relacionam principalmente com a criação da prova real e seu envio na cadeia.
1import { exec } from 'child_process'2import util from 'util'34const execPromise = util.promisify(exec)Precisamos usar o pacote Barretenberg (opens in a new tab) para criar a prova real a ser enviada na cadeia. Podemos usar este pacote executando a interface de linha de comando (bb) ou usando a biblioteca JavaScript, bb.js (opens in a new tab). A biblioteca JavaScript é muito mais lenta do que executar o código nativamente, então usamos exec (opens in a new tab) aqui para usar a linha de comando.
Note que se você decidir usar bb.js, você precisa usar uma versão que seja compatível com a versão do Noir que você está usando. No momento da escrita, a versão atual do Noir (1.0.0-beta.11) usa a versão 0.87 do bb.js.
1const zkBankAddress = process.env.ZKBANK_ADDRESS || "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"O endereço aqui é o que você obtém quando começa com um anvil limpo e segue as instruções acima.
1const walletClient = createWalletClient({ 2 chain: anvil, 3 transport: http(), 4 account: privateKeyToAccount("0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6")5})Esta chave privada é uma das contas pré-financiadas padrão em anvil.
1const generateProof = async (witness, fileID) => {Gere uma prova usando o executável bb.
1 const fname = `witness-${fileID}.gz` 2 await fs.writeFile(fname, witness)Escreva a testemunha em um arquivo.
1 await execPromise(`bb prove -b ./noir/target/zkBank.json -w ${fname} -o ${fileID} --oracle_hash keccak --output_format fields`)Realmente crie a prova. Este passo também cria um arquivo com as variáveis públicas, mas não precisamos disso. Já obtivemos essas variáveis de noir.execute.
1 const proof = "0x" + JSON.parse(await fs.readFile(`./${fileID}/proof_fields.json`)).reduce((a,b) => a+b, "").replace(/0x/g, "")A prova é um array JSON de valores Field, cada um representado como um valor hexadecimal. No entanto, precisamos enviá-lo na transação como um único valor de bytes, que o Viem representa por uma grande string hexadecimal. Aqui, alteramos o formato concatenando todos os valores, removendo todos os 0x's e, em seguida, adicionando um no final.
1 await execPromise(`rm -r ${fname} ${fileID}`)23 return proof4}Limpe e retorne a prova.
1const processMessage = async (message, signature) => {2 .3 .4 .56 const publicFields = noirResult.returnValue.map(x=>'0x' + x.slice(2).padStart(64, "0"))Os campos públicos precisam ser uma matriz de valores de 32 bytes. No entanto, como precisávamos dividir o hash da transação entre dois valores de Field, ele aparece como um valor de 16 bytes. Aqui adicionamos zeros para que Viem entenda que na verdade são 32 bytes.
1 const proof = await generateProof(noirResult.witness, `${fromAddress}-${nonce}`)Cada endereço usa cada nonce apenas uma vez, para que possamos usar uma combinação de fromAddress e nonce como um identificador único para o arquivo testemunha e o diretório de saída.
1 try {2 await zkBank.write.processTransaction([3 proof, publicFields])4 } catch (err) {5 console.log(`Verification error: ${err}`)6 throw Error("Can't verify the transaction onchain")7 }8 .9 .10 .11}Exibir tudoEnvie a transação para a cadeia.
smart-contracts/src/ZkBank.sol
Este é o código na cadeia que recebe a transação.
1// SPDX-License-Identifier: MIT23pragma solidity >=0.8.21;45import {HonkVerifier} from "./Verifier.sol";67contract ZkBank {8 HonkVerifier immutable myVerifier;9 bytes32 currentStateHash;1011 constructor(address _verifierAddress, bytes32 _initialStateHash) {12 currentStateHash = _initialStateHash;13 myVerifier = HonkVerifier(_verifierAddress);14 }Exibir tudoO código na cadeia precisa manter o controle de duas variáveis: o verificador (um contrato separado que é criado por nargo) e o hash do estado atual.
1 event TransactionProcessed(2 bytes32 indexed transactionHash,3 bytes32 oldStateHash,4 bytes32 newStateHash5 );Toda vez que o estado muda, emitimos um evento TransactionProcessed.
1 function processTransaction(2 bytes calldata _proof,3 bytes32[] calldata _publicFields4 ) public {Esta função processa transações. Ele obtém a prova (como bytes) e as entradas públicas (como uma matriz bytes32), no formato que o verificador exige (para minimizar o processamento na cadeia e, portanto, os custos de gás).
1 require(_publicInputs[0] == currentStateHash,2 "Wrong old state hash");A prova de conhecimento zero precisa ser que a transação muda do nosso hash atual para um novo.
1 myVerifier.verify(_proof, _publicFields);Chame o contrato do verificador para verificar a prova de conhecimento zero. Este passo reverte a transação se a prova de conhecimento zero estiver errada.
1 currentStateHash = _publicFields[1];23 emit TransactionProcessed(4 _publicFields[2]<<128 | _publicFields[3],5 _publicFields[0],6 _publicFields[1]7 );8 }9}Exibir tudoSe tudo estiver correto, atualize o hash de estado para o novo valor e emita um evento TransactionProcessed.
Abusos pelo componente centralizado
A segurança da informação consiste em três atributos:
- Confidencialidade, os usuários não podem ler informações que não estão autorizados a ler.
- Integridade, as informações não podem ser alteradas, exceto por usuários autorizados de maneira autorizada.
- Disponibilidade, usuários autorizados podem usar o sistema.
Neste sistema, a integridade é fornecida por meio de provas de conhecimento zero. A disponibilidade é muito mais difícil de garantir, e a confidencialidade é impossível, porque o banco precisa saber o saldo de cada conta e todas as transações. Não há como impedir que uma entidade que possui informações compartilhe essas informações.
Pode ser possível criar um banco verdadeiramente confidencial usando endereços furtivos (opens in a new tab), mas isso está além do escopo deste artigo.
Informações falsas
Uma maneira pela qual o servidor pode violar a integridade é fornecer informações falsas quando os dados são solicitados (opens in a new tab).
Para resolver isso, podemos escrever um segundo programa Noir que recebe as contas como uma entrada privada e o endereço para o qual as informações são solicitadas como uma entrada pública. A saída é o saldo e o nonce desse endereço e o hash das contas.
Claro, essa prova não pode ser verificada na cadeia, porque não queremos publicar nonces e saldos na cadeia. No entanto, pode ser verificado pelo código do cliente em execução no navegador.
Transações forçadas
O mecanismo usual para garantir a disponibilidade e prevenir a censura em L2s é o de transações forçadas (opens in a new tab). Mas as transações forçadas não combinam com as provas de conhecimento zero. O servidor é a única entidade que pode verificar transações.
Podemos modificar smart-contracts/src/ZkBank.sol para aceitar transações forçadas e impedir que o servidor altere o estado até que sejam processadas. No entanto, isso nos abre a um simples ataque de negação de serviço. E se uma transação forçada for inválida e, portanto, impossível de processar?
A solução é ter uma prova de conhecimento zero de que uma transação forçada é inválida. Isso dá ao servidor três opções:
- Processe a transação forçada, fornecendo uma prova de conhecimento zero de que ela foi processada e o novo hash de estado.
- Rejeitar a transação forçada e fornecer uma prova de conhecimento zero ao contrato de que a transação é inválida (endereço desconhecido, nonce incorreto ou saldo insuficiente).
- Ignorar a transação forçada. Não há como forçar o servidor a realmente processar a transação, mas isso significa que todo o sistema está indisponível.
Garantias de disponibilidade
Em uma implementação real, provavelmente haveria algum tipo de motivo de lucro para manter o servidor funcionando. Podemos fortalecer esse incentivo fazendo com que o servidor publique uma garantia de disponibilidade que qualquer pessoa pode queimar se uma transação forçada não for processada dentro de um determinado período.
Código Noir ruim
Normalmente, para que as pessoas confiem em um contrato inteligente, carregamos o código-fonte em um explorador de blocos (opens in a new tab). No entanto, no caso de provas de conhecimento zero, isso é insuficiente.
Verifier.sol contém a chave de verificação, que é uma função do programa Noir. No entanto, essa chave não nos diz qual era o programa Noir. Para realmente ter uma solução confiável, você precisa carregar o programa Noir (e a versão que o criou). Caso contrário, as provas de conhecimento zero podem refletir um programa diferente, um com uma porta dos fundos.
Até que os exploradores de blocos comecem a nos permitir carregar e verificar programas Noir, você deve fazer isso sozinho (de preferência para IPFS). Então, usuários sofisticados poderão baixar o código-fonte, compilá-lo, criar o Verifier.sol e verificar se ele é idêntico ao que está na cadeia.
Conclusão
Os aplicativos do tipo plasma requerem um componente centralizado como armazenamento de informações. Isso abre vulnerabilidades potenciais, mas, em troca, nos permite preservar a privacidade de maneiras não disponíveis na própria blockchain. Com as provas de conhecimento zero, podemos garantir a integridade e, possivelmente, torná-lo economicamente vantajoso para quem estiver executando o componente centralizado para manter a disponibilidade.
Veja aqui mais do meu trabalho (opens in a new tab).
Agradecimentos
- Josh Crites leu um rascunho deste artigo e me ajudou com um problema espinhoso no Noir.
Quaisquer erros remanescentes são de minha responsabilidade.
Última atualização da página: 28 de outubro de 2025