Scrivere un Plasma specifico per l'app che preserva la privacy
Introduzione
A differenza dei rollup, i Plasma utilizzano la Mainnet 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 (gli account e i loro saldi) e delle transazioni, ma mantiene i saldi effettivi offchain dove possono rimanere privati.
Design
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 prover conosce alcuni dati, Dataprivate tali che esista una relazione Relationship tra alcuni dati pubblici, Datapublic, e Dataprivate. Il verificatore conosce Relationship e Datapublic.
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 della transazione.
Nella maggior parte dei casi, Dataprivate è l'input del programma di prova a conoscenza zero e Datapublic è l'output.
Questi campi in Dataprivate:
- Staten, il vecchio stato
- Staten+1, il nuovo stato
- Transaction, 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 in Datapublic:
- Hash(Staten) l'hash del vecchio stato
- Hash(Staten+1) l'hash del nuovo stato
- Hash(Transaction) l'hash della transazione che cambia lo stato da Staten a Staten+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 crittografiche, 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 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.
- Uno smart contract che memorizza gli hash e verifica le prove a conoscenza zero per garantire che le transizioni di stato siano legittime.
Flusso di dati e di controllo
Questi sono i modi in cui i vari componenti comunicano per effettuare un trasferimento da un account a un 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:
- L'hash del nuovo 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
-
Lo smart contract verifica la prova a conoscenza zero.
-
Se la prova a conoscenza zero risulta corretta, lo smart contract esegue queste azioni:
- Aggiorna l'hash dello stato corrente al nuovo hash dello stato
- Emette una voce di log con il nuovo hash dello stato e l'hash della transazione
Strumenti
Per il codice lato client, utilizzeremo 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 utilizzando 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:
noirup -v 1.0.0-beta.10
La blockchain che utilizziamo è 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 più fasi.
Fase 1 - A 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 ricevere tali 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.
git clone https://github.com/qbzzt/250911-zk-bank.git -b 01-manual-zk cd 250911-zk-bank cd client npm install npm run devIl motivo per cui qui è necessario un server web è che, per prevenire determinati tipi di frode, molti portafogli (come MetaMask) non accettano file serviti direttamente dal disco
-
Apri un browser con un portafoglio.
-
Nel portafoglio, inserisci una nuova passphrase. Nota che questo eliminerà la tua passphrase esistente, quindi assicurati di avere un backup.
La passphrase è
test test test test test test test test test test test junk, la passphrase di test predefinita per anvil. -
Connettiti al portafoglio e seleziona l'account di destinazione e l'importo.
-
Fai clic su Sign (Firma) e firma la transazione.
-
Sotto l'intestazione Prover.toml, troverai del testo. Sostituisci
server/noir/Prover.tomlcon quel testo. -
Esegui la prova a conoscenza zero.
cd ../server/noir nargo executeL'output dovrebbe essere simile a
ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute
[zkBank] Circuit witness successfully solved [zkBank] Witness saved to target/zkBank.gz [zkBank] Circuit output: (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85)
10. Confronta gli ultimi due valori con l'hash che vedi sul browser web per verificare se l'hash del messaggio è stato calcolato correttamente.
#### `server/noir/Prover.toml` \{#server-noir-prover-toml\}
[Questo file](https://github.com/qbzzt/250911-zk-bank/blob/01-manual-zk/server/noir/Prover.toml) mostra il formato delle informazioni previsto da Noir.
```toml
message="send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 500 finney (milliEth) 0 "
Il messaggio è in formato testo, il che lo rende facile da comprendere per l'utente (necessario durante la 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 dimensione variabile, quindi è spesso necessario riempire (pad) i dati.
pubKeyX=["0x83",...,"0x75"]
pubKeyY=["0x35",...,"0xa5"]
signature=["0xb1",...,"0x0d"]
Questi tre parametri sono array di byte a dimensione fissa.
[[accounts]]
address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
balance=100_000
nonce=0
[[accounts]]
address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
balance=100_000
nonce=0
Questo è 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.
export default attrs => {
Questa funzione crea il componente React Transfer, che altri file possono importare.
const accounts = [
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
"0x90F79bf6EB2c4f870365E785982E1f101E93b906",
"0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
]
Questi sono gli indirizzi degli account, gli indirizzi creati dalla passphrase test ... test junk. Se desideri utilizzare i tuoi indirizzi, modifica semplicemente questa definizione.
const account = useAccount()
const wallet = createWalletClient({
transport: custom(window.ethereum!)
})
Questi hook di Wagmi (opens in a new tab) ci consentono di accedere alla libreria Viem (opens in a new tab) e al portafoglio.
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.
const sign = async () => {
Questa funzione viene chiamata quando l'utente fa clic sul pulsante Sign (Firma). Il messaggio viene aggiornato automaticamente, ma la firma richiede l'approvazione dell'utente nel portafoglio e non vogliamo richiederla a meno che non sia necessario.
const signature = await wallet.signMessage({
account: fromAccount,
message,
})
Chiedi al portafoglio di firmare il messaggio (opens in a new tab).
const hash = hashMessage(message)
Ottieni l'hash del messaggio. È utile fornirlo all'utente per il debug (del codice Noir).
const pubKey = await recoverPublicKey({
hash,
signature
})
Ottieni la chiave pubblica (opens in a new tab). Questo è richiesto per la funzione ecrecover di Noir (opens in a new tab).
setSignature(signature)
setHash(hash)
setPubKey(pubKey)
Imposta le variabili di stato. Facendo questo si ridisegna il componente (dopo l'uscita della funzione sign) e si mostrano all'utente i valori aggiornati.
let proverToml = `
Il testo per Prover.toml.
message="${message}"
pubKeyX=${hexToArray(pubKey.slice(4,4+2*32))}
pubKeyY=${hexToArray(pubKey.slice(4+2*32))}
Viem ci fornisce la chiave pubblica come 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 da 32 byte per la y della chiave pubblica.
Tuttavia, Noir si aspetta di ricevere queste informazioni come due array di 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 in generale per la conoscenza zero. Il codice all'interno di una prova a conoscenza zero è costoso, quindi qualsiasi elaborazione che può essere eseguita all'esterno della prova a conoscenza zero dovrebbe essere eseguita all'esterno della prova a conoscenza zero.
signature=${hexToArray(signature.slice(2,-2))}
Anche la firma viene fornita come 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.
${accounts.map(accountInProverToml).reduce((a,b) => a+b, "")}
`
Fornisci gli account.
setProverToml(proverToml)
}
return (
<>
<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 vero e proprio codice a conoscenza zero.
use 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 utilizzano 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.
use keccak256::keccak256;
use dep::ecrecover;
Queste due funzioni sono librerie esterne, definite in Nargo.toml (opens in a new tab). Sono esattamente ciò che suggerisce il loro 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.
global 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<number> rappresentano quel numero di bit, senza segno. Gli unici tipi supportati sono u8, u16, u32, u64 e u128.
global FLAT_ACCOUNT_FIELDS : u32 = 2;
Questa variabile viene utilizzata per l'hash di Pedersen degli account, come spiegato di seguito.
global MESSAGE_LENGTH : u32 = 100;
Come spiegato sopra, la lunghezza del messaggio è fissa. Viene specificata qui.
global ASCII_MESSAGE_LENGTH : [u8; 3] = [0x31, 0x30, 0x30];
global 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.
struct Account {
balance: u128,
address: Field,
nonce: u32,
}
Le informazioni che memorizziamo su un account. Field (opens in a new tab) è un numero, in genere fino a 253 bit, che può essere utilizzato direttamente nel circuito aritmetico (opens in a new tab) che implementa la prova a conoscenza zero. Qui utilizziamo il Field per memorizzare un indirizzo Ethereum a 160 bit.
struct TransferTxn {
from: Field,
to: Field,
amount: u128,
nonce: u32
}
Le informazioni che memorizziamo per una transazione di trasferimento.
fn flatten_account(account: Account) -> [Field; FLAT_ACCOUNT_FIELDS] {
Una definizione di funzione. Il parametro è l'informazione Account. Il risultato è un array di variabili Field, la cui lunghezza è FLAT_ACCOUNT_FIELDS
let flat = [
account.address,
((account.balance << 32) + account.nonce.into()).into(),
];
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.
flat
}
In Noir, le funzioni possono restituire un valore solo alla fine (non c'è un ritorno anticipato). Per specificare il valore di ritorno, lo si valuta appena prima della parentesi di chiusura della funzione.
fn 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 utilizzato come input per un hash di Petersen.
let mut flat: [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] = [0; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER];
Questo è il modo in cui si specifica una variabile mutabile, ovvero non una costante. Le variabili in Noir devono sempre avere un valore, quindi inizializziamo questa variabile con tutti zeri.
for i in 0..ACCOUNT_NUMBER {
Questo è un ciclo for. Nota che i limiti sono costanti. I cicli in 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.
let fields = flatten_account(accounts[i]);
for j in 0..FLAT_ACCOUNT_FIELDS {
flat[i*FLAT_ACCOUNT_FIELDS + j] = fields[j];
}
}
flat
}
fn hash_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> Field {
pedersen_hash(flatten_accounts(accounts))
}
Infine, siamo arrivati alla funzione che calcola l'hash dell'array degli account.
fn find_account(accounts: [Account; ACCOUNT_NUMBER], address: Field) -> u32 {
let mut account : u32 = ACCOUNT_NUMBER;
for i in 0..ACCOUNT_NUMBER {
if accounts[i].address == address {
account = i;
}
}
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 verificare una condizione, dobbiamo verificarla ogni volta.
Una cosa simile accade con le istruzioni if. L'istruzione if nel ciclo precedente viene tradotta in queste istruzioni matematiche.
conditionresult = accounts[i].address == address // uno se sono uguali, zero altrimenti
accountnew = conditionresult*i + (1-conditionresult)*accountold
assert (account < ACCOUNT_NUMBER, f"{address} does not have an account");
account
}
La funzione assert (opens in a new tab) causa l'arresto anomalo della prova a conoscenza zero se l'asserzione è falsa. In questo caso, se non riusciamo a trovare un account con l'indirizzo pertinente. Per segnalare l'indirizzo, utilizziamo una stringa di formato (opens in a new tab).
fn apply_transfer_txn(accounts: [Account; ACCOUNT_NUMBER], txn: TransferTxn) -> [Account; ACCOUNT_NUMBER] {
Questa funzione applica una transazione di trasferimento e restituisce il nuovo array degli account.
let from = find_account(accounts, txn.from);
let to = find_account(accounts, txn.to);
let (txnFrom, txnAmount, txnNonce, accountNonce) =
(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 ne creiamo una copia utilizzabile.
assert (accounts[from].balance >= txn.amount,
f"{txnFrom} does not have {txnAmount} finney");
assert (accounts[from].nonce == txn.nonce,
f"Transaction has nonce {txnNonce}, but the account is expected to use {accountNonce}");
Queste sono due condizioni che potrebbero rendere non valida una transazione.
let mut newAccounts = accounts;
newAccounts[from].balance -= txn.amount;
newAccounts[from].nonce += 1;
newAccounts[to].balance += txn.amount;
newAccounts
}
Crea il nuovo array degli account e poi restituiscilo.
fn readAddress(messageBytes: [u8; MESSAGE_LENGTH]) -> Field
Questa funzione legge l'indirizzo dal messaggio.
{
let mut result : Field = 0;
for i in 7..47 {
L'indirizzo è sempre lungo 20 byte (ovvero 40 cifre esadecimali) e inizia dal carattere n. 7.
result *= 0x10;
if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-9
result += (messageBytes[i]-48).into();
}
if messageBytes[i] >= 65 & messageBytes[i] <= 70 { // A-F
result += (messageBytes[i]-65+10).into()
}
if messageBytes[i] >= 97 & messageBytes[i] <= 102 { // a-f
result += (messageBytes[i]-97+10).into()
}
}
result
}
fn readAmountAndNonce(messageBytes: [u8; MESSAGE_LENGTH]) -> (u128, u32)
Leggi l'importo e il nonce dal messaggio.
{
let mut amount : u128 = 0;
let mut nonce: u32 = 0;
let mut stillReadingAmount: bool = true;
let mut lookingForNonce: bool = false;
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 essi viene ignorato.
for i in 48..MESSAGE_LENGTH {
if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-9
let digit = (messageBytes[i]-48);
if stillReadingAmount {
amount = amount*10 + digit.into();
}
if lookingForNonce { // L'abbiamo appena trovato
stillReadingNonce = true;
lookingForNonce = false;
}
if stillReadingNonce {
nonce = nonce*10 + digit.into();
}
} else {
if stillReadingAmount {
stillReadingAmount = false;
lookingForNonce = true;
}
if stillReadingNonce {
stillReadingNonce = false;
}
}
}
(amount, nonce)
}
Restituire una tupla (opens in a new tab) è il modo di Noir per restituire più valori da una funzione.
fn readTransferTxn(message: str<MESSAGE_LENGTH>) -> TransferTxn
{
let mut txn: TransferTxn = TransferTxn { from: 0, to: 0, amount:0, nonce:0 };
let messageBytes = message.as_bytes();
txn.to = readAddress(messageBytes);
let (amount, nonce) = readAmountAndNonce(messageBytes);
txn.amount = amount;
txn.nonce = nonce;
txn
}
Questa funzione converte il messaggio in byte, quindi converte gli importi in un TransferTxn.
// L'equivalente di hashMessage di Viem
// https://viem.sh/docs/utilities/hashMessage#hashmessage
fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {
Siamo stati in grado di utilizzare l'hash di Pedersen per gli account perché il loro hash viene calcolato solo all'interno della prova a conoscenza zero. Tuttavia, in questo codice dobbiamo verificare 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 utilizzare lo standard Ethereum keccak256 per calcolarne l'hash.
// Prefisso ASCII
let prefix_bytes = [
0x19, // \x19
0x45, // 'E'
0x74, // 't'
0x68, // 'h'
0x65, // 'e'
0x72, // 'r'
0x65, // 'e'
0x75, // 'u'
0x6D, // 'm'
0x20, // ' '
0x53, // 'S'
0x69, // 'i'
0x67, // 'g'
0x6E, // 'n'
0x65, // 'e'
0x64, // 'd'
0x20, // ' '
0x4D, // 'M'
0x65, // 'e'
0x73, // 's'
0x73, // 's'
0x61, // 'a'
0x67, // 'g'
0x65, // 'e'
0x3A, // ':'
0x0A // '\n'
];
Per evitare casi in cui un'applicazione chieda all'utente di firmare un messaggio che può essere utilizzato 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 da un ritorno a capo.
let mut buffer: [u8; HASH_BUFFER_SIZE] = [0u8; HASH_BUFFER_SIZE];
for i in 0..26 {
buffer[i] = prefix_bytes[i];
}
let messageBytes : [u8; MESSAGE_LENGTH] = message.as_bytes();
if MESSAGE_LENGTH <= 9 {
for i in 0..1 {
buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
}
for i in 0..MESSAGE_LENGTH {
buffer[i+26+1] = messageBytes[i];
}
}
if MESSAGE_LENGTH >= 10 & MESSAGE_LENGTH <= 99 {
for i in 0..2 {
buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
}
for i in 0..MESSAGE_LENGTH {
buffer[i+26+2] = messageBytes[i];
}
}
if MESSAGE_LENGTH >= 100 {
for i in 0..3 {
buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
}
for i in 0..MESSAGE_LENGTH {
buffer[i+26+3] = messageBytes[i];
}
}
assert(MESSAGE_LENGTH < 1000, "Messages whose length is over three digits are not supported");
Gestisci lunghezze dei messaggi fino a 999 e fallisci se è maggiore. Ho aggiunto questo codice, anche se la lunghezza del messaggio è una costante, perché rende più facile modificarla. In un sistema di produzione, probabilmente si presumerebbe semplicemente che MESSAGE_LENGTH non cambi per ottenere prestazioni migliori.
keccak256::keccak256(buffer, HASH_BUFFER_SIZE)
}
Usa la funzione standard di Ethereum keccak256.
fn signatureToAddressAndHash(
message: str<MESSAGE_LENGTH>,
pubKeyX: [u8; 32],
pubKeyY: [u8; 32],
signature: [u8; 64]
) -> (Field, Field, Field) // indirizzo, primi 16 byte dell'hash, ultimi 16 byte dell'hash
{
Questa funzione verifica la firma, che richiede l'hash del messaggio. Ci fornisce quindi l'indirizzo che lo ha firmato e l'hash del messaggio. L'hash del messaggio viene 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 nell'EVM).
let hash = hashMessage(message);
let mut (hash1, hash2) = (0,0);
for i in 0..16 {
hash1 = hash1*256 + hash[31-i].into();
hash2 = hash2*256 + hash[15-i].into();
}
Specifica hash1 e hash2 come variabili mutabili e scrivi l'hash al loro interno byte per byte.
(
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, si tratta di un'elaborazione che può essere eseguita esternamente e, pertanto, non vale la pena eseguire all'interno della prova a conoscenza zero. Se qualcuno cerca di imbrogliarci qui, la verifica della firma fallirà.
hash1,
hash2
)
}
fn main(
accounts: [Account; ACCOUNT_NUMBER],
message: str<MESSAGE_LENGTH>,
pubKeyX: [u8; 32],
pubKeyY: [u8; 32],
signature: [u8; 64],
) -> pub (
Field, // Hash del vecchio array di account
Field, // Hash del nuovo array di account
Field, // Primi 16 byte dell'hash del messaggio
Field, // Ultimi 16 byte dell'hash del messaggio
)
Infine, 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.
{
let mut txn = readTransferTxn(message);
Abbiamo bisogno che txn sia mutabile perché non leggiamo l'indirizzo del mittente dal messaggio, lo leggiamo dalla firma.
let (fromAddress, txnHash1, txnHash2) = signatureToAddressAndHash(
message,
pubKeyX,
pubKeyY,
signature);
txn.from = fromAddress;
let newAccounts = apply_transfer_txn(accounts, txn);
(
hash_accounts(accounts),
hash_accounts(newAccounts),
txnHash1,
txnHash2
)
}
Fase 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:
-
Interrompi Vite se è in esecuzione.
-
Scarica il branch che include il server e assicurati di avere tutti i moduli necessari.
git checkout 02-add-server cd client npm install cd ../server npm installNon c'è bisogno di compilare il codice Noir, è lo stesso codice che hai usato per la fase 1.
-
Avvia il server.
npm run start -
In una finestra della riga di comando separata, esegui Vite per servire il codice del browser.
cd client npm run dev -
Vai al codice client all'indirizzo http://localhost:5173 (opens in a new tab)
-
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.
Qui abbiamo un dilemma. Da un lato, non vogliamo firmare un messaggio che può essere riutilizzato (un attacco replay (opens in a new tab)), motivo per cui vogliamo un nonce in primo luogo. Tuttavia, non abbiamo ancora un nonce. La soluzione è scegliere un nonce che può essere utilizzato solo una volta e che abbiamo già su entrambi i lati, come l'ora corrente.
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 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.
-
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.
-
Per vedere il trasferimento, fai clic su Update account data (Aggiorna dati account) o guarda nella finestra in cui esegui il server. Il server registra lo stato ogni volta che cambia.
ori@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)
#### `server/index.mjs` \{#server-index-mjs-1\}
[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.
```js
import { 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.
const circuit = JSON.parse(await fs.readFile("./noir/target/zkBank.json"))
const noir = new Noir(circuit)
Carica il circuito aritmetico---il programma Noir compilato che abbiamo creato nella fase precedente---e preparati a eseguirlo.
// Forniamo informazioni sull'account solo in risposta a una richiesta firmata
const accountInformation = async signature => {
const fromAddress = await recoverAddress({
hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),
signature
})
Per fornire le informazioni sull'account, abbiamo solo bisogno della firma. Il motivo è che sappiamo già quale sarà il messaggio e quindi l'hash del messaggio.
const processMessage = async (message, signature) => {
Elabora un messaggio ed esegui la transazione che codifica.
// Ottieni la chiave pubblica
const pubKey = await recoverPublicKey({
hash,
signature
})
Ora che eseguiamo JavaScript sul server, possiamo recuperare la chiave pubblica lì piuttosto che sul client.
let noirResult
try {
noirResult = await noir.execute({
message,
signature: signature.slice(2,-2).match(/.{2}/g).map(x => `0x${x}`),
pubKeyX,
pubKeyY,
accounts: Accounts
})
noir.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 vengono forniti come un array di stringhe esadecimali (["0x60", "0xA7"]), non come un singolo valore esadecimale (0x60A7), come fa Viem.
} catch (err) {
console.log(`Noir error: ${err}`)
throw Error("Invalid transaction, not processed")
}
Se c'è un errore, intercettalo e poi trasmetti una versione semplificata al client.
Accounts[fromAccountNumber].nonce++
Accounts[fromAccountNumber].balance -= amount
Accounts[toAccountNumber].balance += amount
Applica la transazione. Lo abbiamo già fatto nel codice Noir, ma è più facile farlo di nuovo qui piuttosto che estrarre il risultato da lì.
let Accounts = [
{
address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
balance: 5000,
nonce: 0,
},
La struttura iniziale di Accounts.
Fase 3 - Smart contract di Ethereum
-
Interrompi i processi del server e del client.
-
Scarica il branch con gli smart contract e assicurati di avere tutti i moduli necessari.
git checkout 03-smart-contracts cd client npm install cd ../server npm install -
Esegui
anvilin una finestra della riga di comando separata. -
Genera la chiave di verifica e il verificatore Solidity, quindi copia il codice del verificatore nel progetto Solidity.
cd noir bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol cp target/Verifier.sol ../../smart-contracts/src -
Vai agli smart contract e imposta le variabili d'ambiente per utilizzare la blockchain
anvil.cd ../../smart-contracts export ETH_RPC_URL=http://localhost:8545 ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -
Distribuisci
Verifier.sole memorizza l'indirizzo in una variabile d'ambiente.VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'` echo $VERIFIER_ADDRESS -
Distribuisci il contratto
ZkBank.ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'` echo $ZKBANK_ADDRESSIl valore
0x199..67bè l'hash di Pederson dello stato iniziale diAccounts. Se modifichi questo stato iniziale inserver/index.mjs, puoi eseguire una transazione per vedere l'hash iniziale riportato dalla prova a conoscenza zero. -
Avvia il server.
cd ../server npm run start -
Esegui il client in una finestra della riga di comando diversa.
cd client npm run dev -
Esegui alcune transazioni.
-
Per verificare che lo stato sia cambiato onchain, riavvia il processo del server. Nota che
ZkBanknon accetta più transazioni, perché il valore hash originale nelle transazioni differisce dal valore hash memorizzato onchain.Questo è il tipo di errore previsto.
ori@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
#### `server/index.mjs` \{#server-index-mjs-2\}
Le modifiche in questo file riguardano principalmente la creazione della prova effettiva e il suo invio onchain.
```js
import { exec } from 'child_process'
import util from 'util'
const execPromise = util.promisify(exec)
Dobbiamo utilizzare il pacchetto Barretenberg (opens in a new tab) per creare la prova effettiva da inviare onchain. Possiamo utilizzare questo pacchetto eseguendo l'interfaccia a riga di comando (bb) o utilizzando la libreria JavaScript, bb.js (opens in a new tab). La libreria JavaScript è molto più lenta rispetto all'esecuzione nativa del codice, quindi qui utilizziamo exec (opens in a new tab) per utilizzare la riga di comando.
Nota che se decidi di utilizzare bb.js, devi utilizzare una versione compatibile con la versione di Noir che stai utilizzando. Al momento della stesura, la versione attuale di Noir (1.0.0-beta.11) utilizza la versione 0.87 di bb.js.
const zkBankAddress = process.env.ZKBANK_ADDRESS || "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"
L'indirizzo qui è quello che ottieni quando inizi con un anvil pulito e segui le indicazioni sopra.
const walletClient = createWalletClient({
chain: anvil,
transport: http(),
account: privateKeyToAccount("0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6")
})
Questa chiave privata è uno degli account prefinanziati predefiniti in anvil.
const generateProof = async (witness, fileID) => {
Genera una prova utilizzando l'eseguibile bb.
const fname = `witness-${fileID}.gz`
await fs.writeFile(fname, witness)
Scrivi il testimone in un file.
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.
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 valore esadecimale. Tuttavia, dobbiamo inviarla 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 aggiungendone uno alla fine.
await execPromise(`rm -r ${fname} ${fileID}`)
return proof
}
Pulisci e restituisci la prova.
const processMessage = async (message, signature) => {
.
.
.
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 degli zeri in modo che Viem capisca che in realtà si tratta di 32 byte.
const proof = await generateProof(noirResult.witness, `${fromAddress}-${nonce}`)
Ogni indirizzo utilizza ogni nonce solo una volta in modo da poter utilizzare una combinazione di fromAddress e nonce come identificatore univoco per il file del testimone e la directory di output.
try {
await zkBank.write.processTransaction([
proof, publicFields])
} catch (err) {
console.log(`Verification error: ${err}`)
throw Error("Can't verify the transaction onchain")
}
.
.
.
}
Invia la transazione alla catena.
smart-contracts/src/ZkBank.sol
Questo è il codice onchain che riceve la transazione.
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.21;
import {HonkVerifier} from "./Verifier.sol";
contract ZkBank {
HonkVerifier immutable myVerifier;
bytes32 currentStateHash;
constructor(address _verifierAddress, bytes32 _initialStateHash) {
currentStateHash = _initialStateHash;
myVerifier = HonkVerifier(_verifierAddress);
}
Il codice onchain deve tenere traccia di due variabili: il verificatore (un contratto separato creato da nargo) e l'hash dello stato corrente.
event TransactionProcessed(
bytes32 indexed transactionHash,
bytes32 oldStateHash,
bytes32 newStateHash
);
Ogni volta che lo stato cambia, emettiamo un evento TransactionProcessed.
function processTransaction(
bytes calldata _proof,
bytes32[] calldata _publicFields
) public {
Questa funzione elabora le transazioni. Ottiene la prova (come bytes) e gli input pubblici (come array bytes32), nel formato richiesto dal verificatore (per ridurre al minimo l'elaborazione onchain e quindi i costi del gas).
require(_publicInputs[0] == currentStateHash,
"Wrong old state hash");
La prova a conoscenza zero deve dimostrare che la transazione cambia dal nostro hash corrente a uno nuovo.
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 è errata.
currentStateHash = _publicFields[1];
emit TransactionProcessed(
_publicFields[2]<<128 | _publicFields[3],
_publicFields[0],
_publicFields[1]
);
}
}
Se 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 utilizzare il sistema.
In questo sistema, l'integrità è garantita tramite 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 condividerle.
Potrebbe essere possibile creare una banca veramente riservata utilizzando indirizzi stealth (opens in a new tab), ma ciò esula dallo 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 cui 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 onchain, perché non vogliamo pubblicare nonce e saldi onchain. 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à in grado di verificare le transazioni.
Possiamo modificare smart-contracts/src/ZkBank.sol per accettare transazioni forzate e impedire al server di modificare lo stato finché non vengono elaborate. Tuttavia, questo ci espone a un semplice attacco denial-of-service. Cosa succede se una transazione forzata non è valida ed è 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.
Cauzioni di disponibilità
In un'implementazione reale, ci sarebbe probabilmente una sorta di motivo di profitto per mantenere il server in funzione. Possiamo rafforzare questo incentivo facendo in modo che il server depositi una cauzione 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 uno smart contract, carichiamo il codice sorgente su un block explorer (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, è necessario 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é i block explorer non inizieranno a permetterci di caricare e verificare i programmi Noir, dovresti farlo tu stesso (preferibilmente su IPFS). In questo modo, gli utenti esperti potranno scaricare il codice sorgente, compilarlo da soli, creare Verifier.sol e verificare che sia identico a quello onchain.
Conclusione
Le applicazioni di tipo Plasma richiedono un componente centralizzato per l'archiviazione delle informazioni. Questo apre a potenziali vulnerabilità ma, in cambio, ci consente 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 mantenerne 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 relativo a Noir.
Eventuali errori rimanenti sono di mia responsabilità.