Scrivere un plasma specifico per l'app che preserva la privacy
Introduzione
A differenza dei rollup, i plasma utilizzano la rete principale di Ethereum per l'integrità, ma non per la disponibilità. In questo articolo, scriviamo un'applicazione che si comporta come un plasma, con Ethereum che garantisce l'integrità (nessuna modifica non autorizzata) ma non la disponibilità (un componente centralizzato può bloccarsi e disabilitare l'intero sistema).
L'applicazione che scriviamo qui è una banca che preserva la privacy. Diversi indirizzi hanno account con saldi e possono inviare denaro (ETH) ad altri account. La banca pubblica gli hash dello stato (account e relativi saldi) e delle transazioni, ma mantiene i saldi effettivi fuori catena dove possono rimanere privati.
Progettazione
Questo non è un sistema pronto per la produzione, ma uno strumento didattico. Come tale, è scritto con diverse ipotesi semplificative.
-
Pool di account fisso. Esiste un numero specifico di account e ogni account appartiene a un indirizzo predeterminato. Questo rende il sistema molto più semplice perché è difficile gestire strutture dati di dimensioni variabili nelle prove a conoscenza-zero. Per un sistema pronto per la produzione, possiamo usare la radice di Merkle come hash dello stato e fornire prove di Merkle per i saldi richiesti.
-
Archiviazione in memoria. In un sistema di produzione, dobbiamo scrivere tutti i saldi degli account su disco per preservarli in caso di riavvio. Qui, va bene se le informazioni vengono semplicemente perse.
-
Solo trasferimenti. Un sistema di produzione richiederebbe un modo per depositare asset nella banca e per prelevarli. Ma lo scopo qui è solo illustrare il concetto, quindi questa banca è limitata ai trasferimenti.
Prove a conoscenza-zero
A livello fondamentale, una prova a conoscenza-zero dimostra che il dimostratore conosce alcuni dati, Datiprivati tali che esista una relazione Relazione tra alcuni dati pubblici, Datipubblici, e Datiprivati. Il verificatore conosce la Relazione e i Datipubblici.
Per preservare la privacy, abbiamo bisogno che gli stati e le transazioni siano privati. Ma per garantire l'integrità, abbiamo bisogno che l' hash crittografico (opens in a new tab) degli stati sia pubblico. Per dimostrare alle persone che inviano transazioni che quelle transazioni sono realmente avvenute, dobbiamo anche pubblicare gli hash delle transazioni.
Nella maggior parte dei casi, i Datiprivati sono l'input del programma di prova a conoscenza-zero e i Datipubblici sono l'output.
Questi campi nei Datiprivati:
- Staton, il vecchio stato
- Staton+1, il nuovo stato
- Transazione, una transazione che passa dal vecchio stato al nuovo. Questa transazione deve includere questi campi:
- Indirizzo di destinazione che riceve il trasferimento
- Importo trasferito
- Nonce per garantire che ogni transazione possa essere elaborata solo una volta. L'indirizzo di origine non deve essere nella transazione, perché può essere recuperato dalla firma.
- Firma, una firma autorizzata a eseguire la transazione. Nel nostro caso, l'unico indirizzo autorizzato a eseguire una transazione è l'indirizzo di origine. Poiché il nostro sistema a conoscenza-zero funziona in questo modo, abbiamo bisogno anche della chiave pubblica dell'account, oltre alla firma di Ethereum.
Questi sono i campi nei Datipubblici:
- Hash(Staton) l'hash del vecchio stato
- Hash(Staton+1) l'hash del nuovo stato
- Hash(Transazione) l'hash della transazione che cambia lo stato da Staton a Staton+1.
La relazione verifica diverse condizioni:
- Gli hash pubblici sono effettivamente gli hash corretti per i campi privati.
- La transazione, quando applicata al vecchio stato, si traduce nel nuovo stato.
- La firma proviene dall'indirizzo di origine della transazione.
A causa delle proprietà delle funzioni di hash crittografico, dimostrare queste condizioni è sufficiente per garantire l'integrità.
Strutture dati
La struttura dati principale è lo stato mantenuto dal server. Per ogni account, il server tiene traccia del saldo dell'account e di un nonce (opens in a new tab), utilizzato per prevenire gli attacchi di replay (opens in a new tab).
Componenti
Questo sistema richiede due componenti:
- Il server che riceve le transazioni, le elabora e pubblica gli hash sulla catena insieme alle prove a conoscenza-zero.
- Un contratto intelligente che memorizza gli hash e verifica le prove a conoscenza-zero per garantire che le transizioni di stato siano legittime.
Flusso di dati e controllo
Questi sono i modi in cui i vari componenti comunicano per trasferire da un account all'altro.
-
Un browser web invia una transazione firmata richiedendo un trasferimento dall'account del firmatario a un account diverso.
-
Il server verifica che la transazione sia valida:
- Il firmatario ha un account nella banca con un saldo sufficiente.
- Il destinatario ha un account nella banca.
-
Il server calcola il nuovo stato sottraendo l'importo trasferito dal saldo del firmatario e aggiungendolo al saldo del destinatario.
-
Il server calcola una prova a conoscenza-zero che il cambiamento di stato è valido.
-
Il server invia a Ethereum una transazione che include:
- Il nuovo hash dello stato
- L'hash della transazione (in modo che il mittente della transazione possa sapere che è stata elaborata)
- La prova a conoscenza-zero che dimostra che la transizione al nuovo stato è valida
-
Il contratto intelligente verifica la prova a conoscenza-zero.
-
Se la prova a conoscenza-zero è corretta, il contratto intelligente esegue queste azioni:
- Aggiorna l'hash dello stato corrente al nuovo hash dello stato
- Emette una voce di registro con il nuovo hash dello stato e l'hash della transazione
Strumenti
Per il codice lato client, useremo 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). Questi sono strumenti standard del settore; se non hai familiarità con essi, puoi usare questo tutorial.
La maggior parte del server è scritta in JavaScript usando Node (opens in a new tab). La parte a conoscenza-zero è scritta in Noir (opens in a new tab). Abbiamo bisogno della versione 1.0.0-beta.10, quindi dopo aver installato Noir come indicato (opens in a new tab), esegui:
1noirup -v 1.0.0-beta.10La blockchain che usiamo è anvil, una blockchain di test locale che fa parte di Foundry (opens in a new tab).
Implementazione
Poiché si tratta di un sistema complesso, lo implementeremo in fasi.
Fase 1 - Conoscenza-zero manuale
Per la prima fase, firmeremo una transazione nel browser e poi forniremo manualmente le informazioni alla prova a conoscenza-zero. Il codice a conoscenza-zero si aspetta di ottenere quelle informazioni in server/noir/Prover.toml (documentato qui (opens in a new tab)).
Per vederlo in azione:
-
Assicurati di avere Node (opens in a new tab) e Noir (opens in a new tab) installati. Preferibilmente, installali su un sistema UNIX come macOS, Linux o WSL (opens in a new tab).
-
Scarica il codice della fase 1 e avvia il server web per servire il codice client.
1git clone https://github.com/qbzzt/250911-zk-bank.git -b 01-manual-zk2cd 250911-zk-bank3cd client4npm install5npm run dev
12 Il motivo per cui hai bisogno di un server web qui è che, per prevenire determinati tipi di frode, molti portafogli (come MetaMask) non accettano file serviti direttamente dal disco343. Apri un browser con un portafoglio.564. Nel portafoglio, inserisci una nuova frase di recupero. Nota che questo eliminerà la tua frase di recupero esistente, quindi _assicurati di avere un backup_.78 La frase di recupero è `test test test test test test test test test test test junk`, la frase di recupero di test predefinita per anvil.9105. Vai al [codice lato client](http://localhost:5173/).11126. Connettiti al portafoglio e seleziona l'account di destinazione e l'importo.13147. Fai clic su **Sign** (Firma) e firma la transazione.15168. Sotto l'intestazione **Prover.toml**, troverai del testo. Sostituisci `server/noir/Prover.toml` con quel testo.17189. Esegui la prova a conoscenza-zero.1920 ```sh21 cd ../server/noir22 nargo executeMostra tuttoL'output dovrebbe essere simile 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)- Confronta gli ultimi due valori con l'hash che vedi sul browser web per vedere se il messaggio è stato sottoposto ad hash correttamente.
server/noir/Prover.toml
Questo file (opens in a new tab) mostra il formato delle informazioni previsto da Noir.
1message="send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 500 finney (milliEth) 0 "Il messaggio è in formato testo, il che lo rende facile da capire per l'utente (il che è necessario al momento della firma) e da analizzare per il codice Noir. L'importo è quotato in finney per consentire trasferimenti frazionari da un lato, ed essere facilmente leggibile dall'altro. L'ultimo numero è il nonce (opens in a new tab).
La stringa è lunga 100 caratteri. Le prove a conoscenza-zero non gestiscono bene i dati di dimensioni variabili, quindi è spesso necessario riempire i dati (padding).
1pubKeyX=["0x83",...,"0x75"]2pubKeyY=["0x35",...,"0xa5"]3signature=["0xb1",...,"0x0d"]Questi tre parametri sono array di byte a dimensione fissa.
1[[accounts]]2address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"3balance=100_0004nonce=056[[accounts]]7address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"8balance=100_0009nonce=0Mostra tuttoQuesto è il modo per specificare un array di strutture. Per ogni voce, specifichiamo l'indirizzo, il saldo (in milliETH, noto anche come finney (opens in a new tab)) e il valore del nonce successivo.
client/src/Transfer.tsx
Questo file (opens in a new tab) implementa l'elaborazione lato client e genera il file server/noir/Prover.toml (quello che include i parametri a conoscenza-zero).
Ecco la spiegazione delle parti più interessanti.
1export default attrs => {Questa funzione crea il componente React Transfer, che altri file possono importare.
1 const accounts = [2 "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",3 "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",4 "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",5 "0x90F79bf6EB2c4f870365E785982E1f101E93b906",6 "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",7 ]Questi sono gli indirizzi degli account, gli indirizzi creati dalla frase di recupero test ... test junk. Se vuoi usare i tuoi indirizzi, modifica semplicemente questa definizione.
1 const account = useAccount()2 const wallet = createWalletClient({3 transport: custom(window.ethereum!)4 })Questi hook di Wagmi (opens in a new tab) ci permettono di accedere alla libreria viem (opens in a new tab) e al portafoglio.
1 const message = `send ${toAccount} ${ethAmount*1000} finney (milliEth) ${nonce}`.padEnd(100, " ")Questo è il messaggio, riempito con spazi. Ogni volta che una delle variabili useState (opens in a new tab) cambia, il componente viene ridisegnato e message viene aggiornato.
1 const sign = async () => {Questa funzione viene chiamata quando l'utente fa clic sul pulsante Sign. Il messaggio viene aggiornato automaticamente, ma la firma richiede l'approvazione dell'utente nel portafoglio e non vogliamo chiederla a meno che non sia necessario.
1 const signature = await wallet.signMessage({2 account: fromAccount,3 message,4 })Chiedi al portafoglio di firmare il messaggio (opens in a new tab).
1 const hash = hashMessage(message)Ottieni l'hash del messaggio. È utile fornirlo all'utente per il debug (del codice Noir).
1 const pubKey = await recoverPublicKey({2 hash,3 signature4 })Ottieni la chiave pubblica (opens in a new tab). Questo è richiesto per la funzione ecrecover di Noir (opens in a new tab).
1 setSignature(signature)2 setHash(hash)3 setPubKey(pubKey)Imposta le variabili di stato. In questo modo si ridisegna il componente (dopo l'uscita della funzione sign) e si mostrano all'utente i valori aggiornati.
1 let proverToml = `Il testo per Prover.toml.
1message="${message}"23pubKeyX=${hexToArray(pubKey.slice(4,4+2*32))}4pubKeyY=${hexToArray(pubKey.slice(4+2*32))}Viem ci fornisce la chiave pubblica come una stringa esadecimale di 65 byte. Il primo byte è 0x04, un marcatore di versione. Questo è seguito da 32 byte per la x della chiave pubblica e poi 32 byte per la y della chiave pubblica.
Tuttavia, Noir si aspetta di ottenere queste informazioni come array di due byte, uno per x e uno per y. È più facile analizzarlo qui sul client piuttosto che come parte della prova a conoscenza-zero.
Nota che questa è una buona pratica nella conoscenza-zero in generale. Il codice all'interno di una prova a conoscenza-zero è costoso, quindi qualsiasi elaborazione che può essere eseguita al di fuori della prova a conoscenza-zero dovrebbe essere eseguita al di fuori della prova a conoscenza-zero.
1signature=${hexToArray(signature.slice(2,-2))}Anche la firma viene fornita come una stringa esadecimale di 65 byte. Tuttavia, l'ultimo byte è necessario solo per recuperare la chiave pubblica. Poiché la chiave pubblica sarà già fornita al codice Noir, non ne abbiamo bisogno per verificare la firma e il codice Noir non lo richiede.
1${accounts.map(accountInProverToml).reduce((a,b) => a+b, "")}2`Fornisci gli account.
1 setProverToml(proverToml)2 }34 return (5 \<>6 <h2>Transfer</h2>Questo è il formato HTML (più precisamente, JSX (opens in a new tab)) del componente.
server/noir/src/main.nr
Questo file (opens in a new tab) è il codice a conoscenza-zero effettivo.
1use std::hash::pedersen_hash;L'hash di Pedersen (opens in a new tab) è fornito con la libreria standard di Noir (opens in a new tab). Le prove a conoscenza-zero usano comunemente questa funzione di hash. È molto più facile da calcolare all'interno dei circuiti aritmetici (opens in a new tab) rispetto alle funzioni di hash standard.
1use keccak256::keccak256;2use dep::ecrecover;Queste due funzioni sono librerie esterne, definite in Nargo.toml (opens in a new tab). Sono esattamente ciò per cui prendono il nome, una funzione che calcola l'hash keccak256 (opens in a new tab) e una funzione che verifica le firme di Ethereum e recupera l'indirizzo Ethereum del firmatario.
1global ACCOUNT_NUMBER : u32 = 5;Noir è ispirato a Rust (opens in a new tab). Le variabili, per impostazione predefinita, sono costanti. È così che definiamo le costanti di configurazione globali. Nello specifico, ACCOUNT_NUMBER è il numero di account che memorizziamo.
I tipi di dati denominati u<numero> sono quel numero di bit, senza segno. Gli unici tipi supportati sono u8, u16, u32, u64 e u128.
1global FLAT_ACCOUNT_FIELDS : u32 = 2;Questa variabile è usata per l'hash di Pedersen degli account, come spiegato di seguito.
1global MESSAGE_LENGTH : u32 = 100;Come spiegato sopra, la lunghezza del messaggio è fissa. È specificata qui.
1global ASCII_MESSAGE_LENGTH : [u8; 3] = [0x31, 0x30, 0x30];2global HASH_BUFFER_SIZE : u32 = 26+3+MESSAGE_LENGTH;Le firme EIP-191 (opens in a new tab) richiedono un buffer con un prefisso di 26 byte, seguito dalla lunghezza del messaggio in ASCII e infine dal messaggio stesso.
1struct Account {2 balance: u128,3 address: Field,4 nonce: u32,5}Le informazioni che memorizziamo su un account. Field (opens in a new tab) è un numero, in genere fino a 253 bit, che può essere usato direttamente nel circuito aritmetico (opens in a new tab) che implementa la prova a conoscenza-zero. Qui usiamo il Field per memorizzare un indirizzo Ethereum a 160 bit.
1struct TransferTxn {2 from: Field,3 to: Field,4 amount: u128,5 nonce: u326}Le informazioni che memorizziamo per una transazione di trasferimento.
1fn flatten_account(account: Account) -> [Field; FLAT_ACCOUNT_FIELDS] {Una definizione di funzione. Il parametro è l'informazione dell'Account. Il risultato è un array di variabili Field, la cui lunghezza è FLAT_ACCOUNT_FIELDS
1 let flat = [2 account.address,3 ((account.balance << 32) + account.nonce.into()).into(),4 ];Il primo valore nell'array è l'indirizzo dell'account. Il secondo include sia il saldo che il nonce. Le chiamate .into() cambiano un numero nel tipo di dati che deve essere. account.nonce è un valore u32, ma per aggiungerlo a account.balance << 32, un valore u128, deve essere un u128. Questo è il primo .into(). Il secondo converte il risultato u128 in un Field in modo che si adatti all'array.
1 flat2}In Noir, le funzioni possono restituire un valore solo alla fine (non c'è un ritorno anticipato). Per specificare il valore di ritorno, lo valuti appena prima della parentesi di chiusura della funzione.
1fn flatten_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] {Questa funzione trasforma l'array degli account in un array Field, che può essere usato come input per un hash di Petersen.
1 let mut flat: [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] = [0; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER];Questo è il modo in cui specifichi una variabile mutabile, cioè non una costante. Le variabili in Noir devono sempre avere un valore, quindi inizializziamo questa variabile a tutti zeri.
1 for i in 0..ACCOUNT_NUMBER {Questo è un ciclo for. Nota che i limiti sono costanti. I cicli Noir devono avere i loro limiti noti in fase di compilazione. Il motivo è che i circuiti aritmetici non supportano il controllo del flusso. Durante l'elaborazione di un ciclo for, il compilatore inserisce semplicemente il codice al suo interno più volte, una per ogni iterazione.
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}Mostra tuttoInfine, siamo arrivati alla funzione che esegue l'hash dell'array degli account.
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 }Questa funzione trova l'account con un indirizzo specifico. Questa funzione sarebbe terribilmente inefficiente nel codice standard perché itera su tutti gli account, anche dopo aver trovato l'indirizzo.
Tuttavia, nelle prove a conoscenza-zero, non c'è controllo del flusso. Se abbiamo mai bisogno di controllare una condizione, dobbiamo controllarla ogni volta.
Una cosa simile accade con le istruzioni if. L'istruzione if nel ciclo sopra è tradotta in queste istruzioni matematiche.
condizionerisultato = accounts[i].address == address // uno se sono uguali, zero altrimenti
accountnuovo = condizionerisultato*i + (1-condizionerisultato)*accountvecchio
1 assert (account < ACCOUNT_NUMBER, f"{address} does not have an account");23 account4}La funzione assert (opens in a new tab) fa sì che la prova a conoscenza-zero si blocchi se l'asserzione è falsa. In questo caso, se non riusciamo a trovare un account con l'indirizzo pertinente. Per segnalare l'indirizzo, usiamo una stringa di formato (opens in a new tab).
1fn apply_transfer_txn(accounts: [Account; ACCOUNT_NUMBER], txn: TransferTxn) -> [Account; ACCOUNT_NUMBER] {Questa funzione applica una transazione di trasferimento e restituisce il nuovo array di account.
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);Non possiamo accedere agli elementi della struttura all'interno di una stringa di formato in Noir, quindi creiamo una copia utilizzabile.
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}");Queste sono due condizioni che potrebbero rendere non valida una transazione.
1 let mut newAccounts = accounts;23 newAccounts[from].balance -= txn.amount;4 newAccounts[from].nonce += 1;5 newAccounts[to].balance += txn.amount;67 newAccounts8}Crea il nuovo array di account e poi restituiscilo.
1fn readAddress(messageBytes: [u8; MESSAGE_LENGTH]) -> FieldQuesta funzione legge l'indirizzo dal messaggio.
1{2 let mut result : Field = 0;34 for i in 7..47 {L'indirizzo è sempre lungo 20 byte (ovvero 40 cifre esadecimali) e inizia dal carattere #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)Mostra tuttoLeggi l'importo e il nonce dal messaggio.
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;Nel messaggio, il primo numero dopo l'indirizzo è l'importo di finney (ovvero un millesimo di ETH) da trasferire. Il secondo numero è il nonce. Qualsiasi testo tra di loro viene ignorato.
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 { // L'abbiamo appena trovato10 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}Mostra tuttoRestituire una tupla (opens in a new tab) è il modo di Noir per restituire più valori da una funzione.
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}Mostra tuttoQuesta funzione converte il messaggio in byte, quindi converte gli importi in una TransferTxn.
1// L'equivalente di hashMessage di Viem2// https://viem.sh/docs/utilities/hashMessage#hashmessage3fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {Siamo stati in grado di usare l'hash di Pedersen per gli account perché vengono sottoposti ad hash solo all'interno della prova a conoscenza-zero. Tuttavia, in questo codice dobbiamo controllare la firma del messaggio, che viene generata dal browser. Per farlo, dobbiamo seguire il formato di firma di Ethereum in EIP 191 (opens in a new tab). Ciò significa che dobbiamo creare un buffer combinato con un prefisso standard, la lunghezza del messaggio in ASCII e il messaggio stesso, e usare lo standard Ethereum keccak256 per eseguirne l'hash.
1 // Prefisso ASCII2 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 ];Mostra tuttoPer evitare casi in cui un'applicazione chiede all'utente di firmare un messaggio che può essere usato come transazione o per qualche altro scopo, l'EIP 191 specifica che tutti i messaggi firmati iniziano con il carattere 0x19 (non un carattere ASCII valido) seguito da Ethereum Signed Message: e una nuova riga.
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");Mostra tuttoGestisci lunghezze di messaggio fino a 999 e fallisci se è maggiore. Ho aggiunto questo codice, anche se la lunghezza del messaggio è una costante, perché rende più facile cambiarla. In un sistema di produzione, probabilmente presumeresti semplicemente che MESSAGE_LENGTH non cambi per il bene di prestazioni migliori.
1 keccak256::keccak256(buffer, HASH_BUFFER_SIZE)2}Usa la funzione standard di Ethereum keccak256.
1fn signatureToAddressAndHash(2 message: str<MESSAGE_LENGTH>, 3 pubKeyX: [u8; 32],4 pubKeyY: [u8; 32],5 signature: [u8; 64]6 ) -> (Field, Field, Field) // indirizzo, primi 16 byte dell'hash, ultimi 16 byte dell'hash7{Questa funzione verifica la firma, che richiede l'hash del messaggio. Ci fornisce quindi l'indirizzo che l'ha firmato e l'hash del messaggio. L'hash del messaggio è fornito in due valori Field perché sono più facili da usare nel resto del programma rispetto a un array di byte.
Dobbiamo usare due valori Field perché i calcoli dei campi vengono eseguiti modulo (opens in a new tab) un numero grande, ma quel numero è in genere inferiore a 256 bit (altrimenti sarebbe difficile eseguire quei calcoli nella 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 }Specifica hash1 e hash2 come variabili mutabili e scrivi l'hash in esse byte per byte.
1 (2 ecrecover::ecrecover(pubKeyX, pubKeyY, signature, hash), Questo è simile a ecrecover di Solidity (opens in a new tab), con due importanti differenze:
- Se la firma non è valida, la chiamata fallisce un
asserte il programma viene interrotto. - Sebbene la chiave pubblica possa essere recuperata dalla firma e dall'hash, questa è un'elaborazione che può essere eseguita esternamente e, pertanto, non vale la pena farla all'interno della prova a conoscenza-zero. Se qualcuno cerca di imbrogliarci qui, la verifica della firma fallirà.
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 dell'array dei vecchi account14 Field, // Hash dell'array dei nuovi account15 Field, // Primi 16 byte dell'hash del messaggio16 Field, // Ultimi 16 byte dell'hash del messaggio17 )Mostra tuttoInfine, raggiungiamo la funzione main. Dobbiamo dimostrare di avere una transazione che modifica validamente l'hash degli account dal vecchio valore a quello nuovo. Dobbiamo anche dimostrare che ha questo specifico hash della transazione in modo che la persona che l'ha inviata sappia che la sua transazione è stata elaborata.
1{2 let mut txn = readTransferTxn(message);Abbiamo bisogno che txn sia mutabile perché non leggiamo l'indirizzo del mittente dal messaggio, lo leggiamo dalla firma.
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}Mostra tuttoFase 2 - Aggiunta di un server
Nella seconda fase, aggiungiamo un server che riceve e implementa le transazioni di trasferimento dal browser.
Per vederlo in azione:
-
Ferma Vite se è in esecuzione.
-
Scarica il ramo che include il server e assicurati di avere tutti i moduli necessari.
1git checkout 02-add-server2cd client3npm install4cd ../server5npm install
12 Non c'è bisogno di compilare il codice Noir, è lo stesso codice che hai usato per la fase 1.343. Avvia il server.56 ```sh7 npm run start-
In una finestra della riga di comando separata, esegui Vite per servire il codice del browser.
1cd client2npm run dev
125. Vai al codice client su [http://localhost:5173](http://localhost:5173)346. Prima di poter emettere una transazione, devi conoscere il nonce, così come l'importo che puoi inviare. Per ottenere queste informazioni, fai clic su **Update account data** (Aggiorna dati account) e firma il messaggio.56 Abbiamo un dilemma qui. Da un lato, non vogliamo firmare un messaggio che può essere riutilizzato (un [attacco di replay](https://en.wikipedia.org/wiki/Replay_attack)), motivo per cui vogliamo un nonce in primo luogo. Tuttavia, non abbiamo ancora un nonce. La soluzione è scegliere un nonce che può essere usato solo una volta e che abbiamo già su entrambi i lati, come l'ora corrente.78 Il problema con questa soluzione è che l'ora potrebbe non essere perfettamente sincronizzata. Quindi, invece, firmiamo un valore che cambia ogni minuto. Ciò significa che la nostra finestra di vulnerabilità agli attacchi di replay è al massimo di un minuto. Considerando che in produzione la richiesta firmata sarà protetta da TLS e che l'altro lato del tunnel---il server---può già rivelare il saldo e il nonce (deve conoscerli per funzionare), questo è un rischio accettabile.9107. Una volta che il browser riceve indietro il saldo e il nonce, mostra il modulo di trasferimento. Seleziona l'indirizzo di destinazione e l'importo e fai clic su **Transfer** (Trasferisci). Firma questa richiesta.11128. Per vedere il trasferimento, fai clic su **Update account data** o guarda nella finestra in cui esegui il server. Il server registra lo stato ogni volta che cambia.13Mostra tuttoori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start
server@1.0.0 start node --experimental-json-modules index.mjs
Listening on port 3000 Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 processed New state: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 64000 (1) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 100000 (0) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0) 0x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0) 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0) Txn send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 7200 finney (milliEth) 1 processed New state: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 56800 (2) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0) 0x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0) 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0) Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 3000 finney (milliEth) 2 processed New state: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 53800 (3) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0) 0x90F79bf6EB2c4f870365E785982E1f101E93b906 has 139000 (0) 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
12#### `server/index.mjs` \{#server-index-mjs-1\}34[Questo file](https://github.com/qbzzt/250911-zk-bank/blob/02-add-server/server/index.mjs) contiene il processo del server e interagisce con il codice Noir in [`main.nr`](https://github.com/qbzzt/250911-zk-bank/blob/02-add-server/server/noir/src/main.nr). Ecco una spiegazione delle parti interessanti.56```js7import { Noir } from '@noir-lang/noir_js'La libreria noir.js (opens in a new tab) fa da interfaccia tra il codice JavaScript e il codice Noir.
1const circuit = JSON.parse(await fs.readFile("./noir/target/zkBank.json"))2const noir = new Noir(circuit)Carica il circuito aritmetico---il programma Noir compilato che abbiamo creato nella fase precedente---e preparati a eseguirlo.
1// Forniamo informazioni sull'account solo in risposta a una richiesta firmata2const accountInformation = async signature => {3 const fromAddress = await recoverAddress({4 hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),5 signature6 })Per fornire le informazioni sull'account, abbiamo bisogno solo della firma. Il motivo è che sappiamo già quale sarà il messaggio e quindi l'hash del messaggio.
1const processMessage = async (message, signature) => {Elabora un messaggio ed esegui la transazione che codifica.
1 // Ottieni la chiave pubblica2 const pubKey = await recoverPublicKey({3 hash,4 signature5 })Ora che eseguiamo JavaScript sul server, possiamo recuperare la chiave pubblica lì piuttosto che sul client.
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 })Mostra tuttonoir.execute esegue il programma Noir. I parametri sono equivalenti a quelli forniti in Prover.toml (opens in a new tab). Nota che i valori lunghi sono forniti come un array di stringhe esadecimali (["0x60", "0xA7"]), non come un singolo valore esadecimale (0x60A7), nel modo in cui lo fa Viem.
1 } catch (err) {2 console.log(`Noir error: ${err}`)3 throw Error("Invalid transaction, not processed")4 }Se c'è un errore, catturalo e poi trasmetti una versione semplificata al client.
1 Accounts[fromAccountNumber].nonce++2 Accounts[fromAccountNumber].balance -= amount3 Accounts[toAccountNumber].balance += amountApplica la transazione. Lo abbiamo già fatto nel codice Noir, ma è più facile farlo di nuovo qui piuttosto che estrarre il risultato da lì.
1let Accounts = [2 {3 address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",4 balance: 5000,5 nonce: 0,6 },La struttura iniziale di Accounts.
Fase 3 - Contratti intelligenti di Ethereum
-
Ferma i processi del server e del client.
-
Scarica il ramo con i contratti intelligenti e assicurati di avere tutti i moduli necessari.
1git checkout 03-smart-contracts2cd client3npm install4cd ../server5npm install
123. Esegui `anvil` in una finestra della riga di comando separata.344. Genera la chiave di verifica e il verificatore solidity, quindi copia il codice del verificatore nel progetto Solidity.56 ```sh7 cd noir8 bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak9 bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol10 cp target/Verifier.sol ../../smart-contracts/srcMostra tutto-
Vai ai contratti intelligenti e imposta le variabili d'ambiente per usare la blockchain
anvil.1cd ../../smart-contracts2export ETH_RPC_URL=http://localhost:85453ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
126. Distribuisci `Verifier.sol` e memorizza l'indirizzo in una variabile d'ambiente.34 ```sh5 VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'`6 echo $VERIFIER_ADDRESS-
Distribuisci il contratto
ZkBank.1ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'`2echo $ZKBANK_ADDRESS
12 Il valore `0x199..67b` è l'hash di Pederson dello stato iniziale di `Accounts`. Se modifichi questo stato iniziale in `server/index.mjs`, puoi eseguire una transazione per vedere l'hash iniziale riportato dalla prova a conoscenza-zero.348. Avvia il server.56 ```sh7 cd ../server8 npm run start-
Esegui il client in una finestra della riga di comando diversa.
1cd client2npm run dev
1210. Esegui alcune transazioni.3411. Per verificare che lo stato sia cambiato on-chain, riavvia il processo del server. Vedi che `ZkBank` non accetta più transazioni, perché il valore hash originale nelle transazioni differisce dal valore hash memorizzato on-chain.56 Questo è il tipo di errore previsto.7ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start
server@1.0.0 start node --experimental-json-modules index.mjs
Listening on port 3000 Verification error: ContractFunctionExecutionError: The contract function "processTransaction" reverted with the following reason: Wrong old state hash
Contract Call: address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 function: processTransaction(bytes _proof, bytes32[] _publicInputs) args: (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000
12#### `server/index.mjs` \{#server-index-mjs-2\}34Le modifiche in questo file riguardano principalmente la creazione della prova effettiva e il suo invio on-chain.56```js7import { exec } from 'child_process'8import util from 'util'910const execPromise = util.promisify(exec)Mostra tuttoDobbiamo usare il pacchetto Barretenberg (opens in a new tab) per creare la prova effettiva da inviare on-chain. Possiamo usare questo pacchetto eseguendo l'interfaccia a riga di comando (bb) o usando la libreria JavaScript, bb.js (opens in a new tab). La libreria JavaScript è molto più lenta dell'esecuzione nativa del codice, quindi usiamo exec (opens in a new tab) qui per usare la riga di comando.
Nota che se decidi di usare bb.js, devi usare una versione compatibile con la versione di Noir che stai usando. Al momento della stesura, la versione attuale di Noir (1.0.0-beta.11) usa la versione 0.87 di bb.js.
1const zkBankAddress = process.env.ZKBANK_ADDRESS || "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"L'indirizzo qui è quello che ottieni quando inizi con un anvil pulito e segui le indicazioni sopra.
1const walletClient = createWalletClient({ 2 chain: anvil, 3 transport: http(), 4 account: privateKeyToAccount("0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6")5})Questa chiave privata è uno degli account pre-finanziati predefiniti in anvil.
1const generateProof = async (witness, fileID) => {Genera una prova usando l'eseguibile bb.
1 const fname = `witness-${fileID}.gz` 2 await fs.writeFile(fname, witness)Scrivi il testimone (witness) in un file.
1 await execPromise(`bb prove -b ./noir/target/zkBank.json -w ${fname} -o ${fileID} --oracle_hash keccak --output_format fields`)Crea effettivamente la prova. Questo passaggio crea anche un file con le variabili pubbliche, ma non ne abbiamo bisogno. Abbiamo già ottenuto quelle variabili da noir.execute.
1 const proof = "0x" + JSON.parse(await fs.readFile(`./${fileID}/proof_fields.json`)).reduce((a,b) => a+b, "").replace(/0x/g, "")La prova è un array JSON di valori Field, ciascuno rappresentato come un valore esadecimale. Tuttavia, dobbiamo inviarlo nella transazione come un singolo valore bytes, che Viem rappresenta con una grande stringa esadecimale. Qui cambiamo il formato concatenando tutti i valori, rimuovendo tutti gli 0x e poi aggiungendone uno alla fine.
1 await execPromise(`rm -r ${fname} ${fileID}`)23 return proof4}Pulisci e restituisci la prova.
1const processMessage = async (message, signature) => {2 .3 .4 .56 const publicFields = noirResult.returnValue.map(x=>'0x' + x.slice(2).padStart(64, "0"))I campi pubblici devono essere un array di valori a 32 byte. Tuttavia, poiché dovevamo dividere l'hash della transazione tra due valori Field, appare come un valore a 16 byte. Qui aggiungiamo zeri in modo che Viem capisca che in realtà sono 32 byte.
1 const proof = await generateProof(noirResult.witness, `${fromAddress}-${nonce}`)Ogni indirizzo usa ogni nonce solo una volta in modo da poter usare una combinazione di fromAddress e nonce come identificatore univoco per il file del testimone e la directory di output.
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}Mostra tuttoInvia la transazione alla catena.
smart-contracts/src/ZkBank.sol
Questo è il codice on-chain che riceve la transazione.
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 }Mostra tuttoIl codice on-chain deve tenere traccia di due variabili: il verificatore (un contratto separato che viene creato da nargo) e l'hash dello stato corrente.
1 event TransactionProcessed(2 bytes32 indexed transactionHash,3 bytes32 oldStateHash,4 bytes32 newStateHash5 );Ogni volta che lo stato cambia, emettiamo un evento TransactionProcessed.
1 function processTransaction(2 bytes calldata _proof,3 bytes32[] calldata _publicFields4 ) public {Questa funzione elabora le transazioni. Ottiene la prova (come bytes) e gli input pubblici (come un array bytes32), nel formato richiesto dal verificatore (per ridurre al minimo l'elaborazione on-chain e quindi i costi del gas).
1 require(_publicInputs[0] == currentStateHash,2 "Wrong old state hash");La prova a conoscenza-zero deve dimostrare che la transazione cambia dal nostro hash corrente a uno nuovo.
1 myVerifier.verify(_proof, _publicFields);Chiama il contratto del verificatore per verificare la prova a conoscenza-zero. Questo passaggio annulla la transazione se la prova a conoscenza-zero è sbagliata.
1 currentStateHash = _publicFields[1];23 emit TransactionProcessed(4 _publicFields[2]<<128 | _publicFields[3],5 _publicFields[0],6 _publicFields[1]7 );8 }9}Mostra tuttoSe tutto è corretto, aggiorna l'hash dello stato al nuovo valore ed emetti un evento TransactionProcessed.
Abusi da parte del componente centralizzato
La sicurezza delle informazioni è costituita da tre attributi:
- Riservatezza, gli utenti non possono leggere informazioni che non sono autorizzati a leggere.
- Integrità, le informazioni non possono essere modificate se non da utenti autorizzati in modo autorizzato.
- Disponibilità, gli utenti autorizzati possono usare il sistema.
Su questo sistema, l'integrità è fornita attraverso prove a conoscenza-zero. La disponibilità è molto più difficile da garantire e la riservatezza è impossibile, perché la banca deve conoscere il saldo di ogni account e tutte le transazioni. Non c'è modo di impedire a un'entità che possiede informazioni di condividere tali informazioni.
Potrebbe essere possibile creare una banca veramente riservata usando indirizzi stealth (opens in a new tab), ma questo va oltre lo scopo di questo articolo.
Informazioni false
Un modo in cui il server può violare l'integrità è fornire informazioni false quando vengono richiesti i dati (opens in a new tab).
Per risolvere questo problema, possiamo scrivere un secondo programma Noir che riceve gli account come input privato e l'indirizzo per il quale vengono richieste le informazioni come input pubblico. L'output è il saldo e il nonce di quell'indirizzo e l'hash degli account.
Naturalmente, questa prova non può essere verificata on-chain, perché non vogliamo pubblicare nonce e saldi on-chain. Tuttavia, può essere verificata dal codice client in esecuzione nel browser.
Transazioni forzate
Il meccanismo abituale per garantire la disponibilità e prevenire la censura sui L2 sono le transazioni forzate (opens in a new tab). Ma le transazioni forzate non si combinano con le prove a conoscenza-zero. Il server è l'unica entità che può verificare le transazioni.
Possiamo modificare smart-contracts/src/ZkBank.sol per accettare transazioni forzate e impedire al server di cambiare lo stato finché non vengono elaborate. Tuttavia, questo ci espone a un semplice attacco denial-of-service. E se una transazione forzata non fosse valida e quindi impossibile da elaborare?
La soluzione è avere una prova a conoscenza-zero che una transazione forzata non è valida. Questo dà al server tre opzioni:
- Elaborare la transazione forzata, fornendo una prova a conoscenza-zero che è stata elaborata e il nuovo hash dello stato.
- Rifiutare la transazione forzata e fornire una prova a conoscenza-zero al contratto che la transazione non è valida (indirizzo sconosciuto, nonce errato o saldo insufficiente).
- Ignorare la transazione forzata. Non c'è modo di forzare il server a elaborare effettivamente la transazione, ma significa che l'intero sistema non è disponibile.
Vincoli di disponibilità
In un'implementazione reale, ci sarebbe probabilmente un qualche tipo di motivo di profitto per mantenere il server in esecuzione. Possiamo rafforzare questo incentivo facendo in modo che il server pubblichi un vincolo di disponibilità che chiunque può bruciare se una transazione forzata non viene elaborata entro un certo periodo.
Codice Noir errato
Normalmente, per far sì che le persone si fidino di un contratto intelligente, carichiamo il codice sorgente su un esploratore di blocchi (opens in a new tab). Tuttavia, nel caso delle prove a conoscenza-zero, ciò è insufficiente.
Verifier.sol contiene la chiave di verifica, che è una funzione del programma Noir. Tuttavia, quella chiave non ci dice quale fosse il programma Noir. Per avere effettivamente una soluzione fidata, devi caricare il programma Noir (e la versione che lo ha creato). Altrimenti, le prove a conoscenza-zero potrebbero riflettere un programma diverso, uno con una backdoor.
Finché gli esploratori di blocchi non inizieranno a permetterci di caricare e verificare i programmi Noir, dovresti farlo tu stesso (preferibilmente su IPFS). Quindi gli utenti sofisticati saranno in grado di scaricare il codice sorgente, compilarlo da soli, creare Verifier.sol e verificare che sia identico a quello on-chain.
Conclusione
Le applicazioni di tipo plasma richiedono un componente centralizzato come archiviazione delle informazioni. Questo apre a potenziali vulnerabilità ma, in cambio, ci permette di preservare la privacy in modi non disponibili sulla blockchain stessa. Con le prove a conoscenza-zero possiamo garantire l'integrità e possibilmente rendere economicamente vantaggioso per chiunque gestisca il componente centralizzato mantenere la disponibilità.
Vedi qui per altri miei lavori (opens in a new tab).
Ringraziamenti
- Josh Crites ha letto una bozza di questo articolo e mi ha aiutato con uno spinoso problema di Noir.
Eventuali errori rimanenti sono di mia responsabilità.
Ultimo aggiornamento della pagina: 28 ottobre 2025