Passer au contenu principal

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

à divulgation nulle de connaissance
serveur
hors chaîne
confidentialité
Avancé
Ori Pomerantz
15 octobre 2025
35 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, où Ethereum garantit l'intégrité (aucune modification non autorisée) mais pas la disponibilité (un composant centralisé peut tomber en panne et désactiver l'ensemble du système).

L'application que nous écrivons ici est une banque préservant 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 hashs de l'état (les comptes et leurs soldes) et des transactions, mais conserve les soldes réels hors chaîne où ils peuvent rester privés.

Conception

Il ne s'agit pas d'un système prêt pour la production, mais d'un outil pédagogique. À ce titre, 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 hash 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 conserver en cas de redémarrage. Ici, ce n'est pas grave si l'information est simplement perdue.

  • 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 juste 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, Dataprivate telles qu'il existe une relation Relationship entre certaines données publiques, Datapublic, et Dataprivate. Le vérificateur connaît Relationship et Datapublic.

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 hash cryptographique (opens in a new tab) des états soit public. Pour prouver aux personnes qui soumettent des transactions que ces transactions ont réellement eu lieu, nous devons également publier les hachages de transaction.

Dans la plupart des cas, Dataprivate est l'entrée du programme de preuve à divulgation nulle de connaissance, et Datapublic est la sortie.

Ces champs dans Dataprivate :

  • Staten, l'ancien état
  • Staten+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 s'assurer que chaque transaction ne peut être traitée qu'une seule fois. L'adresse source n'a pas besoin d'être 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. En raison du fonctionnement de notre système à divulgation nulle de connaissance, nous avons également besoin de la clé publique du compte, en plus de la signature Ethereum.

Voici les champs dans Datapublic :

  • Hash(Staten) le hash de l'ancien état
  • Hash(Staten+1) le hash du nouvel état
  • Hash(Transaction) le hash de la transaction qui fait passer l'état de Staten à Staten+1.

La relation vérifie plusieurs conditions :

  • Les hashs publics sont bien les hashs corrects 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, prouver ces conditions est suffisant pour garantir l'intégrité.

Structures de données

La structure de données principale est l'état conservé 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 hashs sur la chaîne avec les preuves à divulgation nulle de connaissance.
  • Un contrat intelligent qui stocke les hashs 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

Voici comment 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 dans la banque avec un solde suffisant.
    • Le destinataire a un compte dans 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 attestant que le changement d'état est valide.

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

    • Le hash du nouvel état
    • Le hachage de transaction (pour 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 hash de l'état actuel vers le hash du nouvel état
    • Émettre une entrée de journal avec le hash du nouvel état et le hachage de 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 standards de l'industrie ; si vous ne les connaissez pas, vous pouvez utiliser ce tutoriel.

La majorité du serveur est écrite en JavaScript en utilisant 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 :

noirup -v 1.0.0-beta.10

La chaîne de blocs que nous utilisons est anvil, une chaîne de blocs 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 l'implémenterons par étapes.

Étape 1 - À divulgation nulle de connaissance manuel

Pour la première étape, nous allons signer une transaction dans le navigateur, puis fournir 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 d'avoir installé Node (opens in a new tab) et Noir (opens in a new tab). 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.

    git clone https://github.com/qbzzt/250911-zk-bank.git -b 01-manual-zk
    cd 250911-zk-bank
    cd client
    npm install
    npm 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, entrez une nouvelle phrase secrète. Notez que cela supprimera votre phrase secrète existante, alors 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. Naviguez vers le 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 Sign 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.

    cd ../server/noir
    nargo execute
    

    La sortie devrait être similaire à

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)

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 indiqué 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 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 remplir (pad) les données.

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

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

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, c'est-à-dire en finney (opens in a new tab)), et la valeur du nonce suivant.

client/src/Transfer.tsx

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

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

export default attrs =>  {

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

  const accounts = [
    "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
    "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
    "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
    "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
    "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
  ]

Ce sont les adresses de compte, les adresses créées par la phrase secrète test ... test junk. Si vous souhaitez utiliser vos propres adresses, modifiez simplement cette définition.

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

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.

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

C'est le message, rempli avec des espaces. Chaque fois que l'une des variables useState (opens in a new tab) change, le composant est redessiné et message est mis à jour.

  const sign = async () => {

Cette fonction est appelée lorsque l'utilisateur clique sur le bouton Sign. 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 à moins que ce ne soit nécessaire.

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

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

    const hash = hashMessage(message)

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

    const pubKey = await recoverPublicKey({
        hash,
        signature
    })

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

    setSignature(signature)
    setHash(hash)
    setPubKey(pubKey)

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

    let proverToml = `

Le texte pour Prover.toml.

message="${message}"

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

Viem nous fournit la clé publique sous forme de 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 ces informations sous forme de deux tableaux d'octets, un pour x et un pour y. Il est plus facile de l'analyser ici sur le 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 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.

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

La signature est également fournie sous forme de chaîne hexadécimale de 65 octets. Cependant, le dernier octet n'est nécessaire que pour récupérer la clé publique. Puisque 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.

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

Fournissez les comptes.

    setProverToml(proverToml)
  }

  return (
    <>
        <h2>Transfer</h2>

C'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 à divulgation nulle de connaissance proprement dit.

use std::hash::pedersen_hash;

Le hash 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 des circuits arithmétiques (opens in a new tab) par rapport aux fonctions de hachage standard.

use keccak256::keccak256;
use dep::ecrecover;

Ces deux fonctions sont des bibliothèques externes, définies dans Nargo.toml (opens in a new tab). Elles font précisément ce que leur nom indique : une fonction qui calcule le hash keccak256 (opens in a new tab) et une fonction qui vérifie les signatures Ethereum et récupère l'adresse Ethereum du signataire.

global 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> correspondent à ce nombre de bits, non signés. Les seuls types pris en charge sont u8, u16, u32, u64 et u128.

global FLAT_ACCOUNT_FIELDS : u32 = 2;

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

global MESSAGE_LENGTH : u32 = 100;

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

global ASCII_MESSAGE_LENGTH : [u8; 3] = [0x31, 0x30, 0x30];
global 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.

struct Account {
    balance: u128,
    address: Field,
    nonce: u32,
}

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 implémente la preuve à divulgation nulle de connaissance. Ici, nous utilisons le Field pour stocker une adresse Ethereum de 160 bits.

struct TransferTxn {
    from: Field,
    to: Field,
    amount: u128,
    nonce: u32
}

Les informations que nous stockons pour une transaction de transfert.

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

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

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

La première valeur du 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 qu'il doit être. 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'intègre dans le tableau.

flat
}

Dans Noir, les fonctions ne peuvent renvoyer 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 l'accolade de fermeture de la fonction.

fn 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 hash de Petersen.

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 non constante. Les variables dans Noir doivent toujours avoir une valeur, nous initialisons donc cette variable avec des zéros.

for i in 0..ACCOUNT_NUMBER {

C'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 à l'intérieur plusieurs fois, une fois pour chaque itération.

Enfin, nous sommes arrivés à la fonction qui hache le tableau des comptes.

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;
        }
    }

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 devons 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 instructions mathématiques.

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

accountnew = conditionresult*i + (1-conditionresult)*accountold

    assert (account < ACCOUNT_NUMBER, f"{address} does not have an account");

    account
}

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 de compte avec l'adresse correspondante. Pour signaler l'adresse, nous utilisons une chaîne de formatage (opens in a new tab).

fn 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.

    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);

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

    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}");

Ce sont deux conditions qui pourraient rendre une transaction invalide.

    let mut newAccounts = accounts;

    newAccounts[from].balance -= txn.amount;
    newAccounts[from].nonce += 1;
    newAccounts[to].balance += txn.amount;

    newAccounts
}

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

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

Cette fonction lit l'adresse à partir du message.

{
    let mut result : Field = 0;

    for i in 7..47 {

L'adresse fait toujours 20 octets (soit 40 chiffres hexadécimaux) de long, et commence au caractère n°7.

Lisez le montant et le nonce à partir du message.

{
    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;

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

Renvoyer un tuple (opens in a new tab) est la façon de Noir de renvoyer plusieurs valeurs à partir d'une fonction.

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

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

Nous avons pu utiliser le hash 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.

Pour éviter les cas où une application demande à l'utilisateur de signer un message qui peut être utilisé comme 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.

Gérez les longueurs de message jusqu'à 999 et échouez si elle est supérieure. 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 simplement que MESSAGE_LENGTH ne change pas pour des raisons de meilleures performances.

    keccak256::keccak256(buffer, HASH_BUFFER_SIZE)
}

Utilisez la fonction keccak256 standard d'Ethereum.

fn signatureToAddressAndHash(
        message: str<MESSAGE_LENGTH>, 
        pubKeyX: [u8; 32],
        pubKeyY: [u8; 32],
        signature: [u8; 64]
    ) -> (Field, Field, Field)   // adresse, 16 premiers octets du hash, 16 derniers octets du hash        
{

Cette fonction vérifie la signature, ce qui nécessite le hash du message. Elle nous fournit ensuite l'adresse qui l'a signé et le hash du message. Le hash du message est fourni en deux valeurs Field car celles-ci 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).

    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();
    }

Spécifiez hash1 et hash2 comme variables mutables, et écrivez le hash à l'intérieur octet par octet.

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

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

  • Si la signature n'est pas valide, l'appel échoue à un assert et le programme est interrompu.
  • Bien que la clé publique puisse être récupérée à partir de la signature et du hash, il s'agit d'un traitement qui peut être effectué en externe et, par conséquent, qui ne vaut pas la peine d'être effectué à 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.

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

{
    let mut txn = readTransferTxn(message);

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

Étape 2 - Ajout d'un serveur

Dans la deuxième étape, nous ajoutons un serveur qui reçoit et implémente les transactions de transfert depuis le 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 d'avoir tous les modules nécessaires.

    git checkout 02-add-server
    cd client
    npm install
    cd ../server
    npm install
    

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

  3. Démarrez le serveur.

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

    cd client
    npm run dev
    
  5. Naviguez vers le code client à l'adresse 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 Update account data et signez le message.

    Nous avons un dilemme ici. D'une part, 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. Étant donné 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 récupère le solde et le nonce, il affiche le formulaire de transfert. Sélectionnez l'adresse de destination et le montant, puis cliquez sur Transfer. Signez cette requête.

  8. Pour voir le transfert, cliquez sur Update account data ou regardez dans la fenêtre où vous exécutez le serveur. Le serveur journalise l'état chaque fois qu'il change.

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\}

[Ce fichier](https://github.com/qbzzt/250911-zk-bank/blob/02-add-server/server/index.mjs) contient le processus du serveur et interagit avec le code Noir à [`main.nr`](https://github.com/qbzzt/250911-zk-bank/blob/02-add-server/server/noir/src/main.nr). Voici une explication des parties intéressantes.

```js
import { 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.

const circuit = JSON.parse(await fs.readFile("./noir/target/zkBank.json"))
const 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.

// Nous fournissons uniquement les informations de compte en réponse à une requête signée
const accountInformation = async signature => {
    const fromAddress = await recoverAddress({
        hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),
        signature
    })

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

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

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

    // Obtenir la clé publique
    const pubKey = await recoverPublicKey({
        hash,
        signature
    })

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

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 comme une seule valeur hexadécimale (0x60A7), comme le fait Viem.

    } catch (err) {
        console.log(`Noir error: ${err}`)
        throw Error("Invalid transaction, not processed")
    }

S'il y a une erreur, attrapez-la puis relayez une version simplifiée au client.

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

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

let Accounts = [
    {
        address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
        balance: 5000,
        nonce: 0,
    },

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 d'avoir tous les modules nécessaires.

    git checkout 03-smart-contracts
    cd client
    npm install
    cd ../server
    npm 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 Solidity, puis copiez le code du vérificateur dans le projet 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
    
  5. Allez dans les contrats intelligents et définissez les variables d'environnement pour utiliser la chaîne de blocs anvil.

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

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

    ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'`
    echo $ZKBANK_ADDRESS
    

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

  8. Démarrez le serveur.

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

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

  11. Pour vérifier que l'état a changé onchain, redémarrez le processus du serveur. Constatez que ZkBank n'accepte plus de transactions, car la valeur de hachage d'origine dans les transactions diffère de la valeur de hachage stockée onchain.

    C'est le type d'erreur attendu.

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

Nous devons utiliser le paquet Barretenberg (opens in a new tab) pour créer la preuve proprement dite à envoyer onchain. Nous pouvons utiliser ce paquet soit en exécutant l'interface en 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 native du code, 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.

const 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.

const walletClient = createWalletClient({ 
    chain: anvil, 
    transport: http(), 
    account: privateKeyToAccount("0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6")
})

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

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

Générez une preuve à l'aide de l'exécutable bb.

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

Écrivez le témoin dans un fichier.

    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 à partir de noir.execute.

    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 comme une valeur hexadécimale. Cependant, nous devons l'envoyer dans la transaction comme une seule valeur bytes, que Viem représente par une grande chaîne hexadécimale. Ici, nous modifions le format en concaténant toutes les valeurs, en supprimant tous les 0x, puis en en ajoutant un à la fin.

    await execPromise(`rm -r ${fname} ${fileID}`)

    return proof
}

Nettoyez et renvoyez la preuve.

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

    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 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 en fait de 32 octets.

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

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

Envoyez la transaction à la chaîne.

smart-contracts/src/ZkBank.sol

C'est le code onchain qui reçoit la transaction.

Le code onchain doit garder une trace de deux variables : le vérificateur (un contrat séparé qui est créé par nargo) et le hash de l'état actuel.

    event TransactionProcessed(
        bytes32 indexed transactionHash,
        bytes32 oldStateHash,
        bytes32 newStateHash
    );

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

    function processTransaction(
        bytes calldata _proof,
        bytes32[] calldata _publicFields
    ) public {

Cette fonction traite les transactions. Elle obtient la preuve (sous forme de bytes) et les entrées publiques (sous forme de tableau bytes32), dans le format requis par le vérificateur (pour minimiser le traitement onchain et donc les coûts en gaz).

        require(_publicInputs[0] == currentStateHash,
            "Wrong old state hash");

La preuve à divulgation nulle de connaissance doit prouver que la transaction passe de notre hash actuel à un nouveau.

        myVerifier.verify(_proof, _publicFields);

Appelez 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 fausse.

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

Abus par le composant centralisé

La sécurité de l'information repose sur 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 et 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 possède des informations de les partager.

Il pourrait ê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 comme entrée privée et l'adresse pour laquelle les informations sont demandées comme entrée publique. La sortie est le solde et le nonce de cette adresse, ainsi que le hash des comptes.

Bien sûr, cette preuve ne peut pas être vérifiée onchain, car nous ne voulons pas publier les nonces et les soldes onchain. 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é qui peut vérifier les transactions.

Nous pouvons modifier smart-contracts/src/ZkBank.sol pour accepter les transactions forcées et empêcher le serveur de modifier 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 est d'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 hash d'état.
  • Rejeter la transaction forcée, et fournir une preuve à divulgation nulle de connaissance au contrat 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.

Cautions de disponibilité

Dans une implémentation réelle, il y aurait probablement une sorte de motivation financière pour maintenir le serveur en marche. Nous pouvons renforcer cette incitation en demandant au serveur de déposer 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éversons 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 quel était le programme Noir. Pour avoir réellement une solution de confiance, vous devez téléverser le programme Noir (et la version qui l'a créé). Sinon, les preuves à divulgation nulle de connaissance pourraient refléter un programme différent, avec une porte dérobée.

Jusqu'à ce que les explorateurs de blocs commencent à nous permettre de téléverser et de vérifier les programmes Noir, vous devriez le faire vous-même (de préférence sur IPFS). Ensuite, 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 onchain.

Conclusion

Les applications de type Plasma nécessitent un composant centralisé pour le stockage des informations. Cela crée des vulnérabilités potentielles mais, en contrepartie, nous permet de préserver la confidentialité d'une manière qui n'est pas possible sur la chaîne de blocs elle-même. Avec les preuves à divulgation nulle de connaissance, nous pouvons garantir l'intégrité et éventuellement rendre économiquement avantageux le maintien de la disponibilité pour quiconque gère le composant centralisé.

Découvrez d'autres de mes travaux ici (opens in a new tab).

Remerciements

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

Toute erreur restante est de ma responsabilité.