Passer au contenu principal

Écrire un plasma spécifique à une application qui préserve la confidentialité

preuve à divulgation nulle de connaissance
serveur
hors-chaîne
confidentialité
Avancé
Ori Pomerantz
15 octobre 2025
36 minutes de lecture

Introduction

Contrairement aux rollups, les plasmas utilisent le réseau principal Ethereum pour l'intégrité, mais pas pour la disponibilité. Dans cet article, nous écrivons une application qui se comporte comme un plasma, avec Ethereum garantissant l'intégrité (pas de changements non autorisés) mais pas la disponibilité (un composant centralisé peut tomber en panne et désactiver tout le système).

L'application que nous écrivons ici est une banque qui préserve la confidentialité. Différentes adresses ont des comptes avec des soldes, et elles peuvent envoyer de l'argent (ETH) à d'autres comptes. La banque publie les hachages de l'état (comptes et soldes) et des transactions, mais garde les soldes réels hors chaîne où ils peuvent rester privés.

Conception

Ce n'est pas un système prêt pour la production, mais un outil pédagogique. En tant que tel, il est écrit avec plusieurs hypothèses simplificatrices.

  • Pool de comptes fixe. Il y a un nombre spécifique de comptes, et chaque compte appartient à une adresse prédéterminée. Cela rend le système beaucoup plus simple car il est difficile de gérer des structures de données de taille variable dans les preuves à divulgation nulle de connaissance. Pour un système prêt pour la production, nous pouvons utiliser la racine de Merkle comme hachage d'état et fournir des preuves de Merkle pour les soldes requis.

  • Stockage en mémoire. Sur un système de production, nous devons écrire tous les soldes des comptes sur le disque pour les préserver en cas de redémarrage. Ici, ce n'est pas grave si les informations sont simplement perdues.

  • Transferts uniquement. Un système de production nécessiterait un moyen de déposer des actifs dans la banque et de les retirer. Mais le but ici est simplement d'illustrer le concept, donc cette banque est limitée aux transferts.

Preuves à divulgation nulle de connaissance

À un niveau fondamental, une preuve à divulgation nulle de connaissance montre que le prouveur connaît certaines données, Donnéesprivées, de telle sorte qu'il existe une relation Relation entre certaines données publiques, Donnéespubliques, et Donnéesprivées. Le vérificateur connaît la Relation et les Donnéespubliques.

Pour préserver la confidentialité, nous avons besoin que les états et les transactions soient privés. Mais pour garantir l'intégrité, nous avons besoin que le hachage cryptographique (opens in a new tab) des états soit public. Pour prouver aux personnes qui soumettent des transactions que ces transactions ont bien eu lieu, nous devons également publier les hachages de transaction.

Dans la plupart des cas, Donnéesprivées est l'entrée du programme de preuve à divulgation nulle de connaissance, et Donnéespubliques est la sortie.

Ces champs dans Donnéesprivées :

  • Étatn, l'ancien état
  • Étatn+1, le nouvel état
  • Transaction, une transaction qui passe de l'ancien état au nouveau. Cette transaction doit inclure ces champs :
    • Adresse de destination qui reçoit le transfert
    • Montant transféré
    • Nonce pour garantir que chaque transaction ne puisse être traitée qu'une seule fois. L'adresse source n'a pas besoin de figurer dans la transaction, car elle peut être récupérée à partir de la signature.
  • Signature, une signature qui est autorisée à effectuer la transaction. Dans notre cas, la seule adresse autorisée à effectuer une transaction est l'adresse source. Parce que notre système à divulgation nulle de connaissance fonctionne comme il le fait, nous avons également besoin de la clé publique du compte, en plus de la signature Ethereum.

Voici les champs dans Donnéespubliques :

  • Hachage(Étatn), le hachage de l'ancien état
  • Hachage(Étatn+1), le hachage du nouvel état
  • Hachage(Transaction), le hachage de la transaction qui fait passer l'état de Étatn à Étatn+1.

La relation vérifie plusieurs conditions :

  • Les hachages publics sont bien les bons hachages pour les champs privés.
  • La transaction, lorsqu'elle est appliquée à l'ancien état, aboutit au nouvel état.
  • La signature provient de l'adresse source de la transaction.

En raison des propriétés des fonctions de hachage cryptographique, la preuve de ces conditions suffit à garantir l'intégrité.

Structures de données

La structure de données principale est l'état détenu par le serveur. Pour chaque compte, le serveur garde une trace du solde du compte et d'un nonce (opens in a new tab), utilisé pour empêcher les attaques par rejeu (opens in a new tab).

Composants

Ce système nécessite deux composants :

  • Le serveur qui reçoit les transactions, les traite et publie les hachages sur la chaîne ainsi que les preuves à divulgation nulle de connaissance.
  • Un contrat intelligent qui stocke les hachages et vérifie les preuves à divulgation nulle de connaissance pour s'assurer que les transitions d'état sont légitimes.

Flux de données et de contrôle

Ce sont les façons dont les différents composants communiquent pour effectuer un transfert d'un compte à un autre.

  1. Un navigateur web soumet une transaction signée demandant un transfert du compte du signataire vers un autre compte.

  2. Le serveur vérifie que la transaction est valide :

    • Le signataire a un compte à la banque avec un solde suffisant.
    • Le destinataire a un compte à la banque.
  3. Le serveur calcule le nouvel état en soustrayant le montant transféré du solde du signataire et en l'ajoutant au solde du destinataire.

  4. Le serveur calcule une preuve à divulgation nulle de connaissance que le changement d'état est valide.

  5. Le serveur soumet à Ethereum une transaction qui inclut :

    • Le nouveau hachage d'état
    • Le hachage de la transaction (afin que l'expéditeur de la transaction puisse savoir qu'elle a été traitée)
    • La preuve à divulgation nulle de connaissance qui prouve que la transition vers le nouvel état est valide
  6. Le contrat intelligent vérifie la preuve à divulgation nulle de connaissance.

  7. Si la preuve à divulgation nulle de connaissance est validée, le contrat intelligent effectue ces actions :

    • Mettre à jour le hachage de l'état actuel avec le nouveau hachage d'état
    • Émettre une entrée de journal avec le nouveau hachage d'état et le hachage de la transaction

Outils

Pour le code côté client, nous allons utiliser Vite (opens in a new tab), React (opens in a new tab), Viem (opens in a new tab) et Wagmi (opens in a new tab). Ce sont des outils standard de l'industrie ; si vous ne les connaissez pas, vous pouvez utiliser ce tutoriel.

La majorité du serveur est écrite en JavaScript à l'aide de Node (opens in a new tab). La partie à divulgation nulle de connaissance est écrite en Noir (opens in a new tab). Nous avons besoin de la version 1.0.0-beta.10, donc après avoir installé Noir comme indiqué (opens in a new tab), exécutez :

1noirup -v 1.0.0-beta.10

La blockchain que nous utilisons est anvil, une blockchain de test locale qui fait partie de Foundry (opens in a new tab).

Implémentation

Comme il s'agit d'un système complexe, nous allons l'implémenter par étapes.

Étape 1 - Divulgation nulle de connaissance manuelle

Pour la première étape, nous signerons une transaction dans le navigateur, puis fournirons manuellement les informations à la preuve à divulgation nulle de connaissance. Le code à divulgation nulle de connaissance s'attend à recevoir ces informations dans server/noir/Prover.toml (documenté ici (opens in a new tab)).

Pour le voir en action :

  1. Assurez-vous que Node (opens in a new tab) et Noir (opens in a new tab) sont installés. De préférence, installez-les sur un système UNIX tel que macOS, Linux ou WSL (opens in a new tab).

  2. Téléchargez le code de l'étape 1 et démarrez le serveur web pour servir le code client.

    1git clone https://github.com/qbzzt/250911-zk-bank.git -b 01-manual-zk
    2cd 250911-zk-bank
    3cd client
    4npm install
    5npm run dev

    La raison pour laquelle vous avez besoin d'un serveur web ici est que, pour prévenir certains types de fraude, de nombreux portefeuilles (tels que MetaMask) n'acceptent pas les fichiers servis directement depuis le disque

  3. Ouvrez un navigateur avec un portefeuille.

  4. Dans le portefeuille, saisissez une nouvelle phrase secrète. Notez que cela supprimera votre phrase secrète existante, donc assurez-vous d'avoir une sauvegarde.

    La phrase secrète est test test test test test test test test test test test junk, la phrase secrète de test par défaut pour anvil.

  5. Accédez au code côté client (opens in a new tab).

  6. Connectez-vous au portefeuille et sélectionnez votre compte de destination et le montant.

  7. Cliquez sur Signer et signez la transaction.

  8. Sous l'en-tête Prover.toml, vous trouverez du texte. Remplacez server/noir/Prover.toml par ce texte.

  9. Exécutez la preuve à divulgation nulle de connaissance.

    1cd ../server/noir
    2nargo execute

    La sortie devrait être similaire à

    1ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute
    2
    3[zkBank] Témoin de circuit résolu avec succès
    4[zkBank] Témoin enregistré dans target/zkBank.gz
    5[zkBank] Sortie du circuit : (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85)
  10. Comparez les deux dernières valeurs au hachage que vous voyez sur le navigateur web pour voir si le message est correctement haché.

server/noir/Prover.toml

Ce fichier (opens in a new tab) montre le format d'information attendu par Noir.

1message="send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 500 finney (milliEth) 0 "

Le message est au format texte, ce qui le rend facile à comprendre pour l'utilisateur (ce qui est nécessaire lors de la signature) et à analyser pour le code Noir. Le montant est exprimé en finneys pour permettre des transferts fractionnés d'une part, et être facilement lisible d'autre part. Le dernier nombre est le nonce (opens in a new tab).

La chaîne de caractères fait 100 caractères de long. Les preuves à divulgation nulle de connaissance ne gèrent pas bien les données de taille variable, il est donc souvent nécessaire de compléter les données.

1pubKeyX=["0x83",...,"0x75"]
2pubKeyY=["0x35",...,"0xa5"]
3signature=["0xb1",...,"0x0d"]

Ces trois paramètres sont des tableaux d'octets de taille fixe.

1[[accounts]]
2address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
3balance=100_000
4nonce=0
5
6[[accounts]]
7address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
8balance=100_000
9nonce=0
Afficher tout

C'est la façon de spécifier un tableau de structures. Pour chaque entrée, nous spécifions l'adresse, le solde (en milliETH alias finney (opens in a new tab)), et la valeur de nonce suivante.

client/src/Transfer.tsx

Ce fichier (opens in a new tab) met en œuvre le traitement côté client et génère le fichier server/noir/Prover.toml (celui qui inclut les paramètres de la preuve à divulgation nulle de connaissance).

Voici l'explication des parties les plus intéressantes.

1export default attrs => {

Cette fonction crée le composant React Transfer, que d'autres fichiers peuvent importer.

1 const accounts = [
2 "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
3 "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
4 "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
5 "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
6 "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
7 ]

Ce sont les adresses de compte, les adresses créées par le test... phrase secrète test junk`. Si vous voulez utiliser vos propres adresses, il suffit de modifier cette définition.

1 const account = useAccount()
2 const wallet = createWalletClient({
3 transport: custom(window.ethereum!)
4 })

Ces hooks Wagmi (opens in a new tab) nous permettent d'accéder à la bibliothèque viem (opens in a new tab) et au portefeuille.

1 const message = `send ${toAccount} ${ethAmount*1000} finney (milliEth) ${nonce}`.padEnd(100, " ")

C'est le message, complété par des espaces. Chaque fois qu'une des variables useState (opens in a new tab) change, le composant est redessiné et message est mis à jour.

1 const sign = async () => {

Cette fonction est appelée lorsque l'utilisateur clique sur le bouton Signer. Le message est automatiquement mis à jour, mais la signature nécessite l'approbation de l'utilisateur dans le portefeuille, et nous ne voulons pas la demander sauf si nécessaire.

1 const signature = await wallet.signMessage({
2 account: fromAccount,
3 message,
4 })

Demander au portefeuille de signer le message (opens in a new tab).

1 const hash = hashMessage(message)

Obtenir le hachage du message. Il est utile de le fournir à l'utilisateur pour le débogage (du code Noir).

1 const pubKey = await recoverPublicKey({
2 hash,
3 signature
4 })

Obtenir la clé publique (opens in a new tab). Ceci est requis pour la fonction Noir ecrecover (opens in a new tab).

1 setSignature(signature)
2 setHash(hash)
3 setPubKey(pubKey)

Définir les variables d'état. Cela redessine le composant (après la fin de la fonction sign) et montre à l'utilisateur les valeurs mises à jour.

1 let proverToml = `

Le texte pour Prover.toml.

1message="${message}"
2
3pubKeyX=${hexToArray(pubKey.slice(4,4+2*32))}
4pubKeyY=${hexToArray(pubKey.slice(4+2*32))}

Viem nous fournit la clé publique sous la forme d'une chaîne hexadécimale de 65 octets. Le premier octet est 0x04, un marqueur de version. Il est suivi de 32 octets pour le x de la clé publique, puis de 32 octets pour le y de la clé publique.

Cependant, Noir s'attend à recevoir cette information sous forme de deux tableaux d'octets, un pour x et un pour y. Il est plus facile de l'analyser ici, côté client, plutôt que dans le cadre de la preuve à divulgation nulle de connaissance.

Notez que c'est une bonne pratique en matière de preuve à divulgation nulle de connaissance en général. Le code à l'intérieur d'une preuve à divulgation nulle de connaissance est coûteux, donc tout traitement qui peut être effectué en dehors de la preuve à divulgation nulle de connaissance devrait être effectué en dehors de la preuve à divulgation nulle de connaissance.

1signature=${hexToArray(signature.slice(2,-2))}

La signature est également fournie sous la forme d'une chaîne hexadécimale de 65 octets. Cependant, le dernier octet n'est nécessaire que pour récupérer la clé publique. Comme la clé publique sera déjà fournie au code Noir, nous n'en avons pas besoin pour vérifier la signature, et le code Noir ne l'exige pas.

1${accounts.map(accountInProverToml).reduce((a,b) => a+b, "")}
2`

Fournir les comptes.

1 setProverToml(proverToml)
2 }
3
4 return (
5 <>
6 <h2>Transfer</h2>

Ceci est le format HTML (plus précisément, JSX (opens in a new tab)) du composant.

server/noir/src/main.nr

Ce fichier (opens in a new tab) est le code réel de la preuve à divulgation nulle de connaissance.

1use std::hash::pedersen_hash;

Le hachage de Pedersen (opens in a new tab) est fourni avec la bibliothèque standard de Noir (opens in a new tab). Les preuves à divulgation nulle de connaissance utilisent couramment cette fonction de hachage. Il est beaucoup plus facile à calculer à l'intérieur de circuits arithmétiques (opens in a new tab) par rapport aux fonctions de hachage standard.

1use keccak256::keccak256;
2use dep::ecrecover;

Ces deux fonctions sont des bibliothèques externes, définies dans Nargo.toml (opens in a new tab). Elles sont précisément ce pour quoi elles sont nommées, une fonction qui calcule le hachage keccak256 (opens in a new tab) et une fonction qui vérifie les signatures Ethereum et récupère l'adresse Ethereum du signataire.

1global ACCOUNT_NUMBER : u32 = 5;

Noir est inspiré de Rust (opens in a new tab). Les variables, par défaut, sont des constantes. C'est ainsi que nous définissons les constantes de configuration globales. Plus précisément, ACCOUNT_NUMBER est le nombre de comptes que nous stockons.

Les types de données nommés u<number> sont ce nombre de bits, non signés. Les seuls types pris en charge sont u8, u16, u32, u64 et u128.

1global FLAT_ACCOUNT_FIELDS : u32 = 2;

Cette variable est utilisée pour le hachage de Pedersen des comptes, comme expliqué ci-dessous.

1global MESSAGE_LENGTH : u32 = 100;

Comme expliqué ci-dessus, la longueur du message est fixe. Elle est spécifiée ici.

1global ASCII_MESSAGE_LENGTH : [u8; 3] = [0x31, 0x30, 0x30];
2global HASH_BUFFER_SIZE : u32 = 26+3+MESSAGE_LENGTH;

Les signatures EIP-191 (opens in a new tab) nécessitent un tampon avec un préfixe de 26 octets, suivi de la longueur du message en ASCII, et enfin du message lui-même.

1struct Account {
2 balance: u128,
3 address: Field,
4 nonce: u32,
5}

Les informations que nous stockons sur un compte. Field (opens in a new tab) est un nombre, généralement jusqu'à 253 bits, qui peut être utilisé directement dans le circuit arithmétique (opens in a new tab) qui met en œuvre la preuve à divulgation nulle de connaissance. Ici, nous utilisons le Field pour stocker une adresse Ethereum de 160 bits.

1struct TransferTxn {
2 from: Field,
3 to: Field,
4 amount: u128,
5 nonce: u32
6}

Les informations que nous stockons pour une transaction de transfert.

1fn flatten_account(account: Account) -> [Field; FLAT_ACCOUNT_FIELDS] {

Une définition de fonction. Le paramètre est une information Account. Le résultat est un tableau de variables Field, dont la longueur est FLAT_ACCOUNT_FIELDS

1 let flat = [
2 account.address,
3 ((account.balance << 32) + account.nonce.into()).into(),
4 ];

La première valeur dans le tableau est l'adresse du compte. La seconde inclut à la fois le solde et le nonce. Les appels .into() changent un nombre vers le type de données dont il a besoin. account.nonce est une valeur u32, mais pour l'ajouter à account.balance « 32, une valeur u128, elle doit être un u128. C'est le premier .into(). Le second convertit le résultat u128 en un Field pour qu'il s'insère dans le tableau.

1 flat
2}

Dans Noir, les fonctions ne peuvent retourner une valeur qu'à la fin (il n'y a pas de retour anticipé). Pour spécifier la valeur de retour, vous l'évaluez juste avant le crochet de fermeture de la fonction.

1fn flatten_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] {

Cette fonction transforme le tableau de comptes en un tableau Field, qui peut être utilisé comme entrée pour un hachage de Petersen.

1 let mut flat: [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] = [0; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER];

C'est ainsi que vous spécifiez une variable mutable, c'est-à-dire, pas une constante. Les variables dans Noir doivent toujours avoir une valeur, donc nous initialisons cette variable à tous zéros.

1 for i in 0..ACCOUNT_NUMBER {

Ceci est une boucle for. Notez que les limites sont des constantes. Les boucles Noir doivent avoir leurs limites connues au moment de la compilation. La raison est que les circuits arithmétiques ne prennent pas en charge le contrôle de flux. Lors du traitement d'une boucle for, le compilateur place simplement le code qu'elle contient plusieurs fois, une pour chaque itération.

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 }
6
7 flat
8}
9
10fn hash_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> Field {
11 pedersen_hash(flatten_accounts(accounts))
12}
Afficher tout

Enfin, nous arrivons à la fonction qui hache le tableau des comptes.

1fn find_account(accounts: [Account; ACCOUNT_NUMBER], address: Field) -> u32 {
2 let mut account : u32 = ACCOUNT_NUMBER;
3
4 for i in 0..ACCOUNT_NUMBER {
5 if accounts[i].address == address {
6 account = i;
7 }
8 }

Cette fonction trouve le compte avec une adresse spécifique. Cette fonction serait terriblement inefficace dans un code standard car elle itère sur tous les comptes, même après avoir trouvé l'adresse.

Cependant, dans les preuves à divulgation nulle de connaissance, il n'y a pas de contrôle de flux. Si nous avons besoin de vérifier une condition, nous devons la vérifier à chaque fois.

Une chose similaire se produit avec les instructions if. L'instruction if dans la boucle ci-dessus est traduite en ces énoncés mathématiques.

conditionrésultat = accounts[i].address == address // un s'ils sont égaux, zéro sinon

comptenouveau = conditionrésultat*i + (1-conditionrésultat)*compteancien

1 assert (account < ACCOUNT_NUMBER, f"{address} n'a pas de compte");
2
3 account
4}

La fonction assert (opens in a new tab) provoque le plantage de la preuve à divulgation nulle de connaissance si l'assertion est fausse. Dans ce cas, si nous ne pouvons pas trouver un compte avec l'adresse pertinente. Pour signaler l'adresse, nous utilisons une chaîne de format (opens in a new tab).

1fn apply_transfer_txn(accounts: [Account; ACCOUNT_NUMBER], txn: TransferTxn) -> [Account; ACCOUNT_NUMBER] {

Cette fonction applique une transaction de transfert et renvoie le nouveau tableau de comptes.

1 let from = find_account(accounts, txn.from);
2 let to = find_account(accounts, txn.to);
3
4 let (txnFrom, txnAmount, txnNonce, accountNonce) =
5 (txn.from, txn.amount, txn.nonce, accounts[from].nonce);

Nous ne pouvons pas accéder aux éléments de structure à l'intérieur d'une chaîne de format dans Noir, nous créons donc une copie utilisable.

1 assert (accounts[from].balance >= txn.amount,
2 f"{txnFrom} n'a pas {txnAmount} finney");
3
4 assert (accounts[from].nonce == txn.nonce,
5 f"La transaction a le nonce {txnNonce}, mais le compte est censé utiliser {accountNonce}");

Ce sont deux conditions qui pourraient rendre une transaction invalide.

1 let mut newAccounts = accounts;
2
3 newAccounts[from].balance -= txn.amount;
4 newAccounts[from].nonce += 1;
5 newAccounts[to].balance += txn.amount;
6
7 newAccounts
8}

Créez le nouveau tableau de comptes, puis renvoyez-le.

1fn readAddress(messageBytes: [u8; MESSAGE_LENGTH]) -> Field

Cette fonction lit l'adresse du message.

1{
2 let mut result : Field = 0;
3
4 for i in 7..47 {

L'adresse est toujours longue de 20 octets (alias 40 chiffres hexadécimaux), et commence au caractère #7.

1 result *= 0x10;
2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-9
3 result += (messageBytes[i]-48).into();
4 }
5 if messageBytes[i] >= 65 & messageBytes[i] <= 70 { // A-F
6 result += (messageBytes[i]-65+10).into()
7 }
8 if messageBytes[i] >= 97 & messageBytes[i] <= 102 { // a-f
9 result += (messageBytes[i]-97+10).into()
10 }
11 }
12
13 result
14}
15
16fn readAmountAndNonce(messageBytes: [u8; MESSAGE_LENGTH]) -> (u128, u32)
Afficher tout

Lire le montant et le nonce du message.

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;

Dans le message, le premier nombre après l'adresse est le montant de finney (alias millième d'un ETH) à transférer. Le deuxième nombre est le nonce. Tout texte entre eux est ignoré.

1 for i in 48..MESSAGE_LENGTH {
2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-9
3 let digit = (messageBytes[i]-48);
4
5 if stillReadingAmount {
6 amount = amount*10 + digit.into();
7 }
8
9 if lookingForNonce { // On vient de le trouver
10 stillReadingNonce = true;
11 lookingForNonce = false;
12 }
13
14 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 }
27
28 (amount, nonce)
29}
Afficher tout

Le renvoi d'un tuple (opens in a new tab) est la manière Noir de renvoyer plusieurs valeurs d'une fonction.

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();
5
6 txn.to = readAddress(messageBytes);
7 let (amount, nonce) = readAmountAndNonce(messageBytes);
8 txn.amount = amount;
9 txn.nonce = nonce;
10
11 txn
12}
Afficher tout

Cette fonction convertit le message en octets, puis convertit les montants en un TransferTxn.

1// L'équivalent de hashMessage de Viem
2// https://viem.sh/docs/utilities/hashMessage#hashmessage
3fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {

Nous avons pu utiliser le hachage de Pedersen pour les comptes car ils ne sont hachés qu'à l'intérieur de la preuve à divulgation nulle de connaissance. Cependant, dans ce code, nous devons vérifier la signature du message, qui est générée par le navigateur. Pour cela, nous devons suivre le format de signature Ethereum dans l'EIP 191 (opens in a new tab). Cela signifie que nous devons créer un tampon combiné avec un préfixe standard, la longueur du message en ASCII et le message lui-même, et utiliser le keccak256 standard d'Ethereum pour le hacher.

1 // Préfixe ASCII
2 let prefix_bytes = [
3 0x19, // \x19
4 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 ];
Afficher tout

Pour éviter les cas où une application demande à l'utilisateur de signer un message qui peut être utilisé comme une transaction ou à d'autres fins, l'EIP 191 spécifie que tous les messages signés commencent par le caractère 0x19 (qui n'est pas un caractère ASCII valide) suivi de Ethereum Signed Message: et d'un saut de ligne.

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 }
5
6 let messageBytes : [u8; MESSAGE_LENGTH] = message.as_bytes();
7
8 if MESSAGE_LENGTH <= 9 {
9 for i in 0..1 {
10 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
11 }
12
13 for i in 0..MESSAGE_LENGTH {
14 buffer[i+26+1] = messageBytes[i];
15 }
16 }
17
18 if MESSAGE_LENGTH >= 10 & MESSAGE_LENGTH <= 99 {
19 for i in 0..2 {
20 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
21 }
22
23 for i in 0..MESSAGE_LENGTH {
24 buffer[i+26+2] = messageBytes[i];
25 }
26 }
27
28 if MESSAGE_LENGTH >= 100 {
29 for i in 0..3 {
30 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
31 }
32
33 for i in 0..MESSAGE_LENGTH {
34 buffer[i+26+3] = messageBytes[i];
35 }
36 }
37
38 assert(MESSAGE_LENGTH < 1000, "Les messages dont la longueur est supérieure à trois chiffres ne sont pas pris en charge");
Afficher tout

Gérer les longueurs de message jusqu'à 999 et échouer si c'est plus. J'ai ajouté ce code, même si la longueur du message est une constante, car cela facilite sa modification. Sur un système de production, vous supposeriez probablement que MESSAGE_LENGTH ne change pas pour une meilleure performance.

1 keccak256::keccak256(buffer, HASH_BUFFER_SIZE)
2}

Utiliser la fonction standard d'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) // adresse, 16 premiers octets du hachage, 16 derniers octets du hachage
7{

Cette fonction vérifie la signature, ce qui nécessite le hachage du message. Elle nous fournit ensuite l'adresse qui l'a signé et le hachage du message. Le hachage du message est fourni sous forme de deux valeurs Field car elles sont plus faciles à utiliser dans le reste du programme qu'un tableau d'octets.

Nous devons utiliser deux valeurs Field car les calculs de champ sont effectués modulo (opens in a new tab) un grand nombre, mais ce nombre est généralement inférieur à 256 bits (sinon il serait difficile d'effectuer ces calculs dans l'EVM).

1 let hash = hashMessage(message);
2
3 let mut (hash1, hash2) = (0,0);
4
5 for i in 0..16 {
6 hash1 = hash1*256 + hash[31-i].into();
7 hash2 = hash2*256 + hash[15-i].into();
8 }

Spécifiez hash1 et hash2 comme des variables mutables, et écrivez le hachage dedans octet par octet.

1 (
2 ecrecover::ecrecover(pubKeyX, pubKeyY, signature, hash),

Ceci est similaire à la fonction ecrecover de Solidity (opens in a new tab), avec deux différences importantes :

  • Si la signature n'est pas valide, l'appel échoue sur un assert et le programme est interrompu.
  • Bien que la clé publique puisse être récupérée à partir de la signature et du hachage, il s'agit d'un traitement qui peut être effectué en externe et qui, par conséquent, ne vaut pas la peine d'être fait à l'intérieur de la preuve à divulgation nulle de connaissance. Si quelqu'un essaie de nous tromper ici, la vérification de la signature échouera.
1 hash1,
2 hash2
3 )
4}
5
6fn 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, // Hachage de l'ancien tableau de comptes
14 Field, // Hachage du nouveau tableau de comptes
15 Field, // 16 premiers octets du hachage du message
16 Field, // 16 derniers octets du hachage du message
17 )
Afficher tout

Enfin, nous atteignons la fonction main. Nous devons prouver que nous avons une transaction qui change valablement le hachage des comptes de l'ancienne valeur à la nouvelle. Nous devons également prouver qu'il a ce hachage de transaction spécifique afin que la personne qui l'a envoyé sache que sa transaction a été traitée.

1{
2 let mut txn = readTransferTxn(message);

Nous avons besoin que txn soit mutable car nous ne lisons pas l'adresse d'origine du message, nous la lisons à partir de la signature.

1 let (fromAddress, txnHash1, txnHash2) = signatureToAddressAndHash(
2 message,
3 pubKeyX,
4 pubKeyY,
5 signature);
6
7 txn.from = fromAddress;
8
9 let newAccounts = apply_transfer_txn(accounts, txn);
10
11 (
12 hash_accounts(accounts),
13 hash_accounts(newAccounts),
14 txnHash1,
15 txnHash2
16 )
17}
Afficher tout

Étape 2 - Ajout d'un serveur

Dans la deuxième étape, nous ajoutons un serveur qui reçoit et met en œuvre les transactions de transfert du navigateur.

Pour le voir en action :

  1. Arrêtez Vite s'il est en cours d'exécution.

  2. Téléchargez la branche qui inclut le serveur et assurez-vous que vous avez tous les modules nécessaires.

    1git checkout 02-add-server
    2cd client
    3npm install
    4cd ../server
    5npm install

    Il n'est pas nécessaire de compiler le code Noir, c'est le même code que vous avez utilisé pour l'étape 1.

  3. Démarrer le serveur.

    1npm run start
  4. Dans une fenêtre de ligne de commande distincte, exécutez Vite pour servir le code du navigateur.

    1cd client
    2npm run dev
  5. Accédez au code client sur http://localhost:5173 (opens in a new tab)

  6. Avant de pouvoir émettre une transaction, vous devez connaître le nonce, ainsi que le montant que vous pouvez envoyer. Pour obtenir ces informations, cliquez sur Mettre à jour les données du compte et signez le message.

    Nous avons un dilemme ici. D'un côté, nous ne voulons pas signer un message qui peut être réutilisé (une attaque par rejeu (opens in a new tab)), c'est pourquoi nous voulons un nonce en premier lieu. Cependant, nous n'avons pas encore de nonce. La solution est de choisir un nonce qui ne peut être utilisé qu'une seule fois et que nous avons déjà des deux côtés, comme l'heure actuelle.

    Le problème avec cette solution est que l'heure pourrait ne pas être parfaitement synchronisée. Donc, à la place, nous signons une valeur qui change chaque minute. Cela signifie que notre fenêtre de vulnérabilité aux attaques par rejeu est d'au plus une minute. Considérant qu'en production la requête signée sera protégée par TLS, et que l'autre côté du tunnel - le serveur - peut déjà divulguer le solde et le nonce (il doit les connaître pour fonctionner), c'est un risque acceptable.

  7. Une fois que le navigateur a récupéré le solde et le nonce, il affiche le formulaire de transfert. Sélectionnez l'adresse de destination et le montant, puis cliquez sur Transférer. Signez cette demande.

  8. Pour voir le transfert, soit Mettez à jour les données du compte, soit regardez dans la fenêtre où vous exécutez le serveur. Le serveur enregistre l'état à chaque fois qu'il change.

    1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start
    2
    3> server@1.0.0 start
    4> node --experimental-json-modules index.mjs
    5
    6Écoute sur le port 3000
    7Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 traitée
    8Nouvel état :
    90xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 a 64000 (1)
    100x70997970C51812dc3A010C7d01b50e0d17dc79C8 a 100000 (0)
    110x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC a 100000 (0)
    120x90F79bf6EB2c4f870365E785982E1f101E93b906 a 136000 (0)
    130x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 a 100000 (0)
    14Txn send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 7200 finney (milliEth) 1 traitée
    15Nouvel état :
    160xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 a 56800 (2)
    170x70997970C51812dc3A010C7d01b50e0d17dc79C8 a 107200 (0)
    180x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC a 100000 (0)
    190x90F79bf6EB2c4f870365E785982E1f101E93b906 a 136000 (0)
    200x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 a 100000 (0)
    21Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 3000 finney (milliEth) 2 traitée
    22Nouvel état :
    230xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 a 53800 (3)
    240x70997970C51812dc3A010C7d01b50e0d17dc79C8 a 107200 (0)
    250x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC a 100000 (0)
    260x90F79bf6EB2c4f870365E785982E1f101E93b906 a 139000 (0)
    270x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 a 100000 (0)
    Afficher tout

server/index.mjs

Ce fichier (opens in a new tab) contient le processus serveur, et interagit avec le code Noir à main.nr (opens in a new tab). Voici une explication des parties intéressantes.

1import { Noir } from '@noir-lang/noir_js'

La bibliothèque noir.js (opens in a new tab) fait l'interface entre le code JavaScript et le code Noir.

1const circuit = JSON.parse(await fs.readFile("./noir/target/zkBank.json"))
2const noir = new Noir(circuit)

Chargez le circuit arithmétique - le programme Noir compilé que nous avons créé à l'étape précédente - et préparez-vous à l'exécuter.

1// Nous ne fournissons des informations sur le compte qu'en réponse à une demande signée
2const accountInformation = async signature => {
3 const fromAddress = await recoverAddress({
4 hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),
5 signature
6 })

Pour fournir des informations sur le compte, nous n'avons besoin que de la signature. La raison est que nous savons déjà quel sera le message, et donc le hachage du message.

1const processMessage = async (message, signature) => {

Traitez un message et exécutez la transaction qu'il encode.

1 // Obtenir la clé publique
2 const pubKey = await recoverPublicKey({
3 hash,
4 signature
5 })

Maintenant que nous exécutons JavaScript sur le serveur, nous pouvons récupérer la clé publique là-bas plutôt que sur le client.

1 let noirResult
2 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: Accounts
9 })
Afficher tout

noir.execute exécute le programme Noir. Les paramètres sont équivalents à ceux fournis dans Prover.toml (opens in a new tab). Notez que les valeurs longues sont fournies sous forme de tableau de chaînes hexadécimales (["0x60", "0xA7"]), et non sous forme de valeur hexadécimale unique (0x60A7), comme le fait Viem.

1 } catch (err) {
2 console.log(`Erreur Noir : ${err}`)
3 throw Error("Transaction invalide, non traitée")
4 }

S'il y a une erreur, l'attraper et relayer une version simplifiée au client.

1 Accounts[fromAccountNumber].nonce++
2 Accounts[fromAccountNumber].balance -= amount
3 Accounts[toAccountNumber].balance += amount

Appliquez la transaction. Nous l'avons déjà fait dans le code Noir, mais il est plus facile de le faire à nouveau ici plutôt que d'en extraire le résultat.

1let Accounts = [
2 {
3 address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
4 balance: 5000,
5 nonce: 0,
6 },

La structure Accounts initiale.

Étape 3 - Contrats intelligents Ethereum

  1. Arrêtez les processus du serveur et du client.

  2. Téléchargez la branche avec les contrats intelligents et assurez-vous que vous avez tous les modules nécessaires.

    1git checkout 03-smart-contracts
    2cd client
    3npm install
    4cd ../server
    5npm install
  3. Exécutez anvil dans une fenêtre de ligne de commande séparée.

  4. Générez la clé de vérification et le vérificateur de solidité, puis copiez le code du vérificateur dans le projet Solidity.

    1cd noir
    2bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak
    3bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol
    4cp target/Verifier.sol ../../smart-contracts/src
  5. Allez dans les contrats intelligents et définissez les variables d'environnement pour utiliser la blockchain anvil.

    1cd ../../smart-contracts
    2export ETH_RPC_URL=http://localhost:8545
    3ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
  6. Déployez Verifier.sol et stockez l'adresse dans une variable d'environnement.

    1VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'`
    2echo $VERIFIER_ADDRESS
  7. Déployez le contrat 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

    La valeur 0x199..67b est le hachage de Pederson de l'état initial de Comptes. Si vous modifiez cet état initial dans server/index.mjs, vous pouvez exécuter une transaction pour voir le hachage initial rapporté par la preuve à divulgation nulle de connaissance.

  8. Lancez le serveur.

    1cd ../server
    2npm run start
  9. Exécutez le client dans une autre fenêtre de ligne de commande.

    1cd client
    2npm run dev
  10. Exécutez quelques transactions.

  11. Pour vérifier que l'état a changé sur la chaîne, redémarrez le processus du serveur. Voyez que ZkBank n'accepte plus les transactions, car la valeur de hachage originale dans les transactions diffère de la valeur de hachage stockée sur la chaîne.

    C'est le type d'erreur attendu.

    1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start
    2
    3> server@1.0.0 start
    4> node --experimental-json-modules index.mjs
    5
    6Écoute sur le port 3000
    7Erreur de vérification : ContractFunctionExecutionError : La fonction de contrat "processTransaction" a été annulée pour la raison suivante :
    8Mauvais hachage de l'ancien état
    9
    10Appel de contrat :
    11 adresse : 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
    12 fonction : processTransaction(bytes _proof, bytes32[] _publicInputs)
    13 args : (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000
    Afficher tout

server/index.mjs

Les changements dans ce fichier concernent principalement la création de la preuve réelle et sa soumission sur la chaîne.

1import { exec } from 'child_process'
2import util from 'util'
3
4const execPromise = util.promisify(exec)

Nous devons utiliser le paquet Barretenberg (opens in a new tab) pour créer la preuve réelle à envoyer sur la chaîne. Nous pouvons utiliser ce paquet soit en exécutant l'interface de ligne de commande (bb) soit en utilisant la bibliothèque JavaScript, bb.js (opens in a new tab). La bibliothèque JavaScript est beaucoup plus lente que l'exécution de code nativement, nous utilisons donc exec (opens in a new tab) ici pour utiliser la ligne de commande.

Notez que si vous décidez d'utiliser bb.js, vous devez utiliser une version compatible avec la version de Noir que vous utilisez. Au moment de la rédaction, la version actuelle de Noir (1.0.0-beta.11) utilise la version 0.87 de bb.js.

1const zkBankAddress = process.env.ZKBANK_ADDRESS || "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"

L'adresse ici est celle que vous obtenez lorsque vous commencez avec un anvil propre et suivez les instructions ci-dessus.

1const walletClient = createWalletClient({
2 chain: anvil,
3 transport: http(),
4 account: privateKeyToAccount("0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6")
5})

Cette clé privée est l'un des comptes pré-financés par défaut dans anvil.

1const generateProof = async (witness, fileID) => {

Générer une preuve en utilisant l'exécutable bb.

1 const fname = `witness-${fileID}.gz`
2 await fs.writeFile(fname, witness)

Écrivez le témoin dans un fichier.

1 await execPromise(`bb prove -b ./noir/target/zkBank.json -w ${fname} -o ${fileID} --oracle_hash keccak --output_format fields`)

Créez réellement la preuve. Cette étape crée également un fichier avec les variables publiques, mais nous n'en avons pas besoin. Nous avons déjà obtenu ces variables de noir.execute.

1 const proof = "0x" + JSON.parse(await fs.readFile(`./${fileID}/proof_fields.json`)).reduce((a,b) => a+b, "").replace(/0x/g, "")

La preuve est un tableau JSON de valeurs Field, chacune représentée par une valeur hexadécimale. Cependant, nous devons l'envoyer dans la transaction en tant que valeur bytes unique, que Viem représente par une grande chaîne hexadécimale. Ici, nous changeons le format en concaténant toutes les valeurs, en supprimant tous les 0x, puis en en ajoutant un à la fin.

1 await execPromise(`rm -r ${fname} ${fileID}`)
2
3 return proof
4}

Nettoyez et retournez la preuve.

1const processMessage = async (message, signature) => {
2 .
3 .
4 .
5
6 const publicFields = noirResult.returnValue.map(x=>'0x' + x.slice(2).padStart(64, "0"))

Les champs publics doivent être un tableau de valeurs de 32 octets. Cependant, comme nous devions diviser le hachage de la transaction entre deux valeurs Field, il apparaît comme une valeur de 16 octets. Ici, nous ajoutons des zéros pour que Viem comprenne qu'il s'agit bien de 32 octets.

1 const proof = await generateProof(noirResult.witness, `${fromAddress}-${nonce}`)

Chaque adresse n'utilise chaque nonce qu'une seule fois, de sorte que nous pouvons utiliser une combinaison de fromAddress et nonce comme identifiant unique pour le fichier témoin et le répertoire de sortie.

1 try {
2 await zkBank.write.processTransaction([
3 proof, publicFields])
4 } catch (err) {
5 console.log(`Erreur de vérification : ${err}`)
6 throw Error("Impossible de vérifier la transaction sur la chaîne")
7 }
8 .
9 .
10 .
11}
Afficher tout

Envoyez la transaction à la chaîne.

smart-contracts/src/ZkBank.sol

Ceci est le code sur la chaîne qui reçoit la transaction.

1// SPDX-License-Identifier: MIT
2
3pragma solidity >=0.8.21;
4
5import {HonkVerifier} from "./Verifier.sol";
6
7contract ZkBank {
8 HonkVerifier immutable myVerifier;
9 bytes32 currentStateHash;
10
11 constructor(address _verifierAddress, bytes32 _initialStateHash) {
12 currentStateHash = _initialStateHash;
13 myVerifier = HonkVerifier(_verifierAddress);
14 }
Afficher tout

Le code sur la chaîne doit garder une trace de deux variables : le vérificateur (un contrat séparé créé par nargo) et le hachage de l'état actuel.

1 event TransactionProcessed(
2 bytes32 indexed transactionHash,
3 bytes32 oldStateHash,
4 bytes32 newStateHash
5 );

Chaque fois que l'état change, nous émettons un événement TransactionProcessed.

1 function processTransaction(
2 bytes calldata _proof,
3 bytes32[] calldata _publicFields
4 ) public {

Cette fonction traite les transactions. Elle reçoit la preuve (en tant que bytes) et les entrées publiques (en tant que tableau bytes32), dans le format requis par le vérificateur (pour minimiser le traitement sur la chaîne et donc les coûts de gaz).

1 require(_publicInputs[0] == currentStateHash,
2 "Mauvais hachage de l'ancien état");

La preuve à divulgation nulle de connaissance doit être que la transaction passe de notre hachage actuel à un nouveau.

1 myVerifier.verify(_proof, _publicFields);

Appeler le contrat vérificateur pour vérifier la preuve à divulgation nulle de connaissance. Cette étape annule la transaction si la preuve à divulgation nulle de connaissance est incorrecte.

1 currentStateHash = _publicFields[1];
2
3 emit TransactionProcessed(
4 _publicFields[2]<<128 | _publicFields[3],
5 _publicFields[0],
6 _publicFields[1]
7 );
8 }
9}
Afficher tout

Si tout est correct, mettez à jour le hachage d'état avec la nouvelle valeur et émettez un événement TransactionProcessed.

Abus par le composant centralisé

La sécurité de l'information se compose de trois attributs :

  • Confidentialité, les utilisateurs ne peuvent pas lire les informations qu'ils ne sont pas autorisés à lire.
  • Intégrité, les informations ne peuvent être modifiées que par des utilisateurs autorisés d'une manière autorisée.
  • Disponibilité, les utilisateurs autorisés peuvent utiliser le système.

Sur ce système, l'intégrité est assurée par des preuves à divulgation nulle de connaissance. La disponibilité est beaucoup plus difficile à garantir, et la confidentialité est impossible, car la banque doit connaître le solde de chaque compte et toutes les transactions. Il n'y a aucun moyen d'empêcher une entité qui détient des informations de les partager.

Il serait peut-être possible de créer une banque véritablement confidentielle en utilisant des adresses furtives (opens in a new tab), mais cela dépasse le cadre de cet article.

Fausses informations

Une façon pour le serveur de violer l'intégrité est de fournir de fausses informations lorsque des données sont demandées (opens in a new tab).

Pour résoudre ce problème, nous pouvons écrire un deuxième programme Noir qui reçoit les comptes en tant qu'entrée privée et l'adresse pour laquelle des informations sont demandées en tant qu'entrée publique. La sortie est le solde et le nonce de cette adresse, et le hachage des comptes.

Bien sûr, cette preuve ne peut pas être vérifiée sur la chaîne, car nous ne voulons pas publier de nonces et de soldes sur la chaîne. Cependant, elle peut être vérifiée par le code client s'exécutant dans le navigateur.

Transactions forcées

Le mécanisme habituel pour garantir la disponibilité et empêcher la censure sur les L2 est les transactions forcées (opens in a new tab). Mais les transactions forcées ne se combinent pas avec les preuves à divulgation nulle de connaissance. Le serveur est la seule entité capable de vérifier les transactions.

Nous pouvons modifier smart-contracts/src/ZkBank.sol pour accepter les transactions forcées et empêcher le serveur de changer l'état jusqu'à ce qu'elles soient traitées. Cependant, cela nous expose à une simple attaque par déni de service. Que se passe-t-il si une transaction forcée est invalide et donc impossible à traiter ?

La solution consiste à avoir une preuve à divulgation nulle de connaissance qu'une transaction forcée est invalide. Cela donne au serveur trois options :

  • Traiter la transaction forcée, en fournissant une preuve à divulgation nulle de connaissance qu'elle a été traitée et le nouveau hachage d'état.
  • Rejeter la transaction forcée et fournir au contrat une preuve à divulgation nulle de connaissance que la transaction est invalide (adresse inconnue, mauvais nonce ou solde insuffisant).
  • Ignorer la transaction forcée. Il n'y a aucun moyen de forcer le serveur à traiter réellement la transaction, mais cela signifie que l'ensemble du système est indisponible.

Cautionnement de disponibilité

Dans une implémentation réelle, il y aurait probablement une sorte de motivation de profit pour maintenir le serveur en fonctionnement. Nous pouvons renforcer cette incitation en faisant en sorte que le serveur dépose une caution de disponibilité que n'importe qui peut brûler si une transaction forcée n'est pas traitée dans un certain délai.

Mauvais code Noir

Normalement, pour que les gens fassent confiance à un contrat intelligent, nous téléchargeons le code source sur un explorateur de blocs (opens in a new tab). Cependant, dans le cas des preuves à divulgation nulle de connaissance, cela est insuffisant.

Verifier.sol contient la clé de vérification, qui est une fonction du programme Noir. Cependant, cette clé ne nous dit pas ce qu'était le programme Noir. Pour avoir une solution réellement fiable, vous devez télécharger le programme Noir (et la version qui l'a créé). Sinon, les preuves à divulgation nulle de connaissance pourraient refléter un programme différent, un programme avec une porte dérobée.

Jusqu'à ce que les explorateurs de blocs nous permettent de télécharger et de vérifier les programmes Noir, vous devriez le faire vous-même (de préférence sur IPFS). Alors, les utilisateurs avertis pourront télécharger le code source, le compiler eux-mêmes, créer Verifier.sol, et vérifier qu'il est identique à celui sur la chaîne.

Conclusion

Les applications de type Plasma nécessitent un composant centralisé pour le stockage des informations. Cela ouvre des vulnérabilités potentielles mais, en retour, nous permet de préserver la confidentialité de manières non disponibles sur la blockchain elle-même. Avec les preuves à divulgation nulle de connaissance, nous pouvons garantir l'intégrité et éventuellement rendre économiquement avantageux pour quiconque exécute le composant centralisé de maintenir la disponibilité.

Voir ici pour plus de mon travail (opens in a new tab).

Remerciements

  • Josh Crites a lu une ébauche de cet article et m'a aidé avec un problème épineux de Noir.

Toute erreur restante est de ma responsabilité.

Dernière mise à jour de la page : 28 octobre 2025

Ce tutoriel vous a été utile ?