Zum Hauptinhalt springen

Schreiben Sie ein anwendungsspezifisches Plasma, das die Privatsphäre wahrt

Zero-Knowledge
Server
Off-Chain
Privatsphäre
Experte
Ori Pomerantz
15. Oktober 2025
32 Minuten Lesezeit

Einführung

Im Gegensatz zu Rollups nutzen Plasmas das Ethereum-Mainnet für die Integrität, aber nicht für die Verfügbarkeit. In diesem Artikel schreiben wir eine Anwendung, die sich wie ein Plasma verhält, wobei Ethereum die Integrität garantiert (keine unbefugten Änderungen), aber nicht die Verfügbarkeit (eine zentralisierte Komponente kann ausfallen und das gesamte System lahmlegen).

Die Anwendung, die wir hier schreiben, ist eine Bank, die die Privatsphäre wahrt. Verschiedene Adressen haben Konten mit Guthaben, und sie können Geld (ETH) an andere Konten senden. Die Bank veröffentlicht Hashes des Zustands (Konten und deren Guthaben) und Transaktionen, hält aber die tatsächlichen Guthaben Off-Chain, wo sie privat bleiben können.

Design

Dies ist kein produktionsreifes System, sondern ein Lehrmittel. Als solches wurde es mit einigen vereinfachenden Annahmen geschrieben.

  • Fester Kontenpool. Es gibt eine bestimmte Anzahl von Konten, und jedes Konto gehört zu einer vorgegebenen Adresse. Dies macht das System viel einfacher, da es schwierig ist, Datenstrukturen variabler Größe in Zero-Knowledge-Beweisen zu handhaben. Für ein produktionsreifes System können wir die Merkle-Wurzel als Zustands-Hash verwenden und Merkle-Beweise für die erforderlichen Guthaben bereitstellen.

  • Speicherung im Arbeitsspeicher. In einem Produktionssystem müssen wir alle Kontostände auf die Festplatte schreiben, um sie im Falle eines Neustarts zu erhalten. Hier ist es in Ordnung, wenn die Informationen einfach verloren gehen.

  • Nur Überweisungen. Ein Produktionssystem würde eine Möglichkeit erfordern, Vermögenswerte bei der Bank einzuzahlen und abzuheben. Aber der Zweck hier ist nur, das Konzept zu veranschaulichen, daher ist diese Bank auf Überweisungen beschränkt.

Zero-Knowledge-Beweise

Auf einer grundlegenden Ebene zeigt ein Zero-Knowledge-Beweis, dass der Beweiser bestimmte Daten kennt, Dataprivate, sodass eine Beziehung Relationship zwischen einigen öffentlichen Daten, Datapublic, und Dataprivate besteht. Der Verifizierer kennt Relationship und Datapublic.

Um die Privatsphäre zu wahren, müssen die Zustände und die Transaktionen privat sein. Um jedoch die Integrität zu gewährleisten, muss der kryptografische Hash (opens in a new tab) der Zustände öffentlich sein. Um den Personen, die Transaktionen einreichen, zu beweisen, dass diese Transaktionen wirklich stattgefunden haben, müssen wir auch Transaktions-Hashes veröffentlichen.

In den meisten Fällen ist Dataprivate die Eingabe für das Zero-Knowledge-Beweisprogramm und Datapublic die Ausgabe.

Diese Felder in Dataprivate:

  • Staten, der alte Zustand
  • Staten+1, der neue Zustand
  • Transaction, eine Transaktion, die vom alten Zustand in den neuen wechselt. Diese Transaktion muss folgende Felder enthalten:
    • Zieladresse, die die Überweisung empfängt
    • Betrag, der überwiesen wird
    • Nonce, um sicherzustellen, dass jede Transaktion nur einmal verarbeitet werden kann. Die Quelladresse muss nicht in der Transaktion enthalten sein, da sie aus der Signatur wiederhergestellt werden kann.
  • Signatur, eine Signatur, die autorisiert ist, die Transaktion durchzuführen. In unserem Fall ist die einzige Adresse, die zur Durchführung einer Transaktion autorisiert ist, die Quelladresse. Da unser Zero-Knowledge-System so funktioniert, wie es funktioniert, benötigen wir zusätzlich zur Ethereum-Signatur auch den Public-Key des Kontos.

Dies sind die Felder in Datapublic:

  • Hash(Staten) der Hash des alten Zustands
  • Hash(Staten+1) der Hash des neuen Zustands
  • Hash(Transaction) der Hash der Transaktion, die den Zustand von Staten zu Staten+1 ändert.

Die Beziehung prüft mehrere Bedingungen:

  • Die öffentlichen Hashes sind tatsächlich die korrekten Hashes für die privaten Felder.
  • Die Transaktion führt, wenn sie auf den alten Zustand angewendet wird, zum neuen Zustand.
  • Die Signatur stammt von der Quelladresse der Transaktion.

Aufgrund der Eigenschaften kryptografischer Hash-Funktionen reicht der Beweis dieser Bedingungen aus, um die Integrität zu gewährleisten.

Datenstrukturen

Die primäre Datenstruktur ist der vom Server gehaltene Zustand. Für jedes Konto verfolgt der Server den Kontostand und eine Nonce (opens in a new tab), die verwendet wird, um Replay-Angriffe (opens in a new tab) zu verhindern.

Komponenten

Dieses System erfordert zwei Komponenten:

  • Der Server, der Transaktionen empfängt, sie verarbeitet und Hashes zusammen mit den Zero-Knowledge-Beweisen auf der Blockchain veröffentlicht.
  • Ein Smart Contract, der die Hashes speichert und die Zero-Knowledge-Beweise verifiziert, um sicherzustellen, dass Zustandsübergänge legitim sind.

Daten- und Kontrollfluss

Dies sind die Wege, auf denen die verschiedenen Komponenten kommunizieren, um von einem Konto auf ein anderes zu überweisen.

  1. Ein Webbrowser reicht eine signierte Transaktion ein, die um eine Überweisung vom Konto des Unterzeichners auf ein anderes Konto bittet.

  2. Der Server verifiziert, dass die Transaktion gültig ist:

    • Der Unterzeichner hat ein Konto bei der Bank mit ausreichendem Guthaben.
    • Der Empfänger hat ein Konto bei der Bank.
  3. Der Server berechnet den neuen Zustand, indem er den überwiesenen Betrag vom Guthaben des Unterzeichners abzieht und ihn dem Guthaben des Empfängers hinzufügt.

  4. Der Server berechnet einen Zero-Knowledge-Beweis, dass die Zustandsänderung gültig ist.

  5. Der Server reicht bei Ethereum eine Transaktion ein, die Folgendes enthält:

    • Den neuen Zustands-Hash
    • Den Transaktions-Hash (damit der Sender der Transaktion weiß, dass sie verarbeitet wurde)
    • Den Zero-Knowledge-Beweis, der belegt, dass der Übergang in den neuen Zustand gültig ist
  6. Der Smart Contract verifiziert den Zero-Knowledge-Beweis.

  7. Wenn der Zero-Knowledge-Beweis erfolgreich ist, führt der Smart Contract diese Aktionen aus:

    • Aktualisierung des aktuellen Zustands-Hashes auf den neuen Zustands-Hash
    • Ausgabe eines Protokolleintrags mit dem neuen Zustands-Hash und dem Transaktions-Hash

Werkzeuge

Für den clientseitigen Code werden wir Vite (opens in a new tab), React (opens in a new tab), Viem (opens in a new tab) und Wagmi (opens in a new tab) verwenden. Dies sind branchenübliche Werkzeuge; wenn Sie nicht mit ihnen vertraut sind, können Sie dieses Tutorial verwenden.

Der Großteil des Servers ist in JavaScript unter Verwendung von Node (opens in a new tab) geschrieben. Der Zero-Knowledge-Teil ist in Noir (opens in a new tab) geschrieben. Wir benötigen Version 1.0.0-beta.10, also führen Sie nach der Installation von Noir gemäß Anleitung (opens in a new tab) Folgendes aus:

1noirup -v 1.0.0-beta.10

Die Blockchain, die wir verwenden, ist anvil, eine lokale Test-Blockchain, die Teil von Foundry (opens in a new tab) ist.

Implementierung

Da dies ein komplexes System ist, werden wir es in Phasen implementieren.

Phase 1 - Manuelles Zero-Knowledge

Für die erste Phase werden wir eine Transaktion im Browser signieren und dann die Informationen manuell dem Zero-Knowledge-Beweis zur Verfügung stellen. Der Zero-Knowledge-Code erwartet, diese Informationen in server/noir/Prover.toml zu erhalten (dokumentiert hier (opens in a new tab)).

Um es in Aktion zu sehen:

  1. Stellen Sie sicher, dass Sie Node (opens in a new tab) und Noir (opens in a new tab) installiert haben. Installieren Sie sie vorzugsweise auf einem UNIX-System wie macOS, Linux oder WSL (opens in a new tab).

  2. Laden Sie den Code für Phase 1 herunter und starten Sie den Webserver, um den Client-Code bereitzustellen.

    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
1
2 Der Grund, warum Sie hier einen Webserver benötigen, ist, dass viele Wallets (wie MetaMask) zur Verhinderung bestimmter Arten von Betrug keine Dateien akzeptieren, die direkt von der Festplatte bereitgestellt werden.
3
43. Öffnen Sie einen Browser mit einem Wallet.
5
64. Geben Sie im Wallet eine neue Passphrase ein. Beachten Sie, dass dadurch Ihre bestehende Passphrase gelöscht wird, _stellen Sie also sicher, dass Sie ein Backup haben_.
7
8 Die Passphrase lautet `test test test test test test test test test test test junk`, die Standard-Test-Passphrase für anvil.
9
105. Navigieren Sie zum [clientseitigen Code](http://localhost:5173/).
11
126. Verbinden Sie sich mit dem Wallet und wählen Sie Ihr Zielkonto und den Betrag aus.
13
147. Klicken Sie auf **Sign** und signieren Sie die Transaktion.
15
168. Unter der Überschrift **Prover.toml** finden Sie Text. Ersetzen Sie `server/noir/Prover.toml` durch diesen Text.
17
189. Führen Sie den Zero-Knowledge-Beweis aus.
19
20 ```sh
21 cd ../server/noir
22 nargo execute
Alle anzeigen

Die Ausgabe sollte ähnlich sein wie

1ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute
2
3[zkBank] Circuit witness successfully solved
4[zkBank] Witness saved to target/zkBank.gz
5[zkBank] Circuit output: (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85)
  1. Vergleichen Sie die letzten beiden Werte mit dem Hash, den Sie im Webbrowser sehen, um zu überprüfen, ob die Nachricht korrekt gehasht wurde.

server/noir/Prover.toml

Diese Datei (opens in a new tab) zeigt das von Noir erwartete Informationsformat.

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

Die Nachricht liegt im Textformat vor, was es dem Benutzer leicht macht, sie zu verstehen (was beim Signieren notwendig ist), und dem Noir-Code, sie zu parsen. Der Betrag ist in Finneys angegeben, um einerseits Teilüberweisungen zu ermöglichen und andererseits leicht lesbar zu sein. Die letzte Zahl ist die Nonce (opens in a new tab).

Die Zeichenfolge ist 100 Zeichen lang. Zero-Knowledge-Beweise können mit Daten variabler Größe nicht gut umgehen, daher ist es oft notwendig, Daten aufzufüllen (Padding).

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

Diese drei Parameter sind Byte-Arrays fester Größe.

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

Dies ist die Art und Weise, ein Array von Strukturen anzugeben. Für jeden Eintrag geben wir die Adresse, das Guthaben (in milliETH, auch bekannt als Finney (opens in a new tab)) und den nächsten Nonce-Wert an.

client/src/Transfer.tsx

Diese Datei (opens in a new tab) implementiert die clientseitige Verarbeitung und generiert die Datei server/noir/Prover.toml (diejenige, die die Zero-Knowledge-Parameter enthält).

Hier ist die Erklärung der interessanteren Teile.

1export default attrs => {

Diese Funktion erstellt die React-Komponente Transfer, die von anderen Dateien importiert werden kann.

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

Dies sind die Kontoadressen, die Adressen, die durch die Passphrase test ... test junk erstellt wurden. Wenn Sie Ihre eigenen Adressen verwenden möchten, ändern Sie einfach diese Definition.

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

Diese Wagmi-Hooks (opens in a new tab) ermöglichen uns den Zugriff auf die viem (opens in a new tab)-Bibliothek und das Wallet.

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

Dies ist die Nachricht, aufgefüllt mit Leerzeichen. Jedes Mal, wenn sich eine der useState (opens in a new tab)-Variablen ändert, wird die Komponente neu gezeichnet und message aktualisiert.

1 const sign = async () => {

Diese Funktion wird aufgerufen, wenn der Benutzer auf die Schaltfläche Sign klickt. Die Nachricht wird automatisch aktualisiert, aber die Signatur erfordert die Zustimmung des Benutzers im Wallet, und wir möchten nicht danach fragen, es sei denn, es ist erforderlich.

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

Bitten Sie das Wallet, die Nachricht zu signieren (opens in a new tab).

1 const hash = hashMessage(message)

Rufen Sie den Nachrichten-Hash ab. Es ist hilfreich, ihn dem Benutzer zum Debuggen (des Noir-Codes) zur Verfügung zu stellen.

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

Rufen Sie den Public-Key ab (opens in a new tab). Dies ist für die Noir-Funktion ecrecover (opens in a new tab) erforderlich.

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

Legen Sie die Zustandsvariablen fest. Dadurch wird die Komponente neu gezeichnet (nachdem die Funktion sign beendet ist) und dem Benutzer werden die aktualisierten Werte angezeigt.

1 let proverToml = `

Der Text für Prover.toml.

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

Viem stellt uns den Public-Key als 65-Byte-Hexadezimalzeichenfolge zur Verfügung. Das erste Byte ist 0x04, eine Versionsmarkierung. Darauf folgen 32 Bytes für das x des Public-Keys und dann 32 Bytes für das y des Public-Keys.

Noir erwartet diese Informationen jedoch als zwei Byte-Arrays, eines für x und eines für y. Es ist einfacher, sie hier auf dem Client zu parsen, als als Teil des Zero-Knowledge-Beweises.

Beachten Sie, dass dies im Allgemeinen eine gute Praxis bei Zero-Knowledge ist. Code innerhalb eines Zero-Knowledge-Beweises ist teuer, daher sollte jede Verarbeitung, die außerhalb des Zero-Knowledge-Beweises durchgeführt werden kann, auch außerhalb des Zero-Knowledge-Beweises durchgeführt werden.

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

Die Signatur wird ebenfalls als 65-Byte-Hexadezimalzeichenfolge bereitgestellt. Das letzte Byte ist jedoch nur erforderlich, um den Public-Key wiederherzustellen. Da der Public-Key dem Noir-Code bereits zur Verfügung gestellt wird, benötigen wir ihn nicht zur Verifizierung der Signatur, und der Noir-Code erfordert ihn nicht.

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

Stellen Sie die Konten bereit.

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

Dies ist das HTML-Format (genauer gesagt JSX (opens in a new tab)) der Komponente.

server/noir/src/main.nr

Diese Datei (opens in a new tab) ist der eigentliche Zero-Knowledge-Code.

1use std::hash::pedersen_hash;

Der Pedersen-Hash (opens in a new tab) wird mit der Noir-Standardbibliothek (opens in a new tab) bereitgestellt. Zero-Knowledge-Beweise verwenden diese Hash-Funktion häufig. Sie ist innerhalb von arithmetischen Schaltkreisen (opens in a new tab) im Vergleich zu den Standard-Hash-Funktionen viel einfacher zu berechnen.

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

Diese beiden Funktionen sind externe Bibliotheken, die in Nargo.toml (opens in a new tab) definiert sind. Sie sind genau das, wonach sie benannt sind: eine Funktion, die den keccak256-Hash (opens in a new tab) berechnet, und eine Funktion, die Ethereum-Signaturen verifiziert und die Ethereum-Adresse des Unterzeichners wiederherstellt.

1global ACCOUNT_NUMBER : u32 = 5;

Noir ist von Rust (opens in a new tab) inspiriert. Variablen sind standardmäßig Konstanten. So definieren wir globale Konfigurationskonstanten. Insbesondere ist ACCOUNT_NUMBER die Anzahl der Konten, die wir speichern.

Datentypen mit dem Namen u<number> haben diese Anzahl von Bits, vorzeichenlos. Die einzigen unterstützten Typen sind u8, u16, u32, u64 und u128.

1global FLAT_ACCOUNT_FIELDS : u32 = 2;

Diese Variable wird für den Pedersen-Hash der Konten verwendet, wie unten erklärt.

1global MESSAGE_LENGTH : u32 = 100;

Wie oben erklärt, ist die Nachrichtenlänge fest. Sie wird hier angegeben.

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

EIP-191-Signaturen (opens in a new tab) erfordern einen Puffer mit einem 26-Byte-Präfix, gefolgt von der Nachrichtenlänge in ASCII und schließlich der Nachricht selbst.

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

Die Informationen, die wir über ein Konto speichern. Field (opens in a new tab) ist eine Zahl, typischerweise bis zu 253 Bits, die direkt in dem arithmetischen Schaltkreis (opens in a new tab) verwendet werden kann, der den Zero-Knowledge-Beweis implementiert. Hier verwenden wir das Field, um eine 160-Bit-Ethereum-Adresse zu speichern.

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

Die Informationen, die wir für eine Überweisungstransaktion speichern.

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

Eine Funktionsdefinition. Der Parameter sind Account-Informationen. Das Ergebnis ist ein Array von Field-Variablen, dessen Länge FLAT_ACCOUNT_FIELDS ist.

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

Der erste Wert im Array ist die Kontoadresse. Der zweite enthält sowohl das Guthaben als auch die Nonce. Die .into()-Aufrufe ändern eine Zahl in den Datentyp, den sie haben muss. account.nonce ist ein u32-Wert, aber um ihn zu account.balance << 32, einem u128-Wert, hinzuzufügen, muss er ein u128 sein. Das ist das erste .into(). Das zweite konvertiert das u128-Ergebnis in ein Field, damit es in das Array passt.

1 flat
2}

In Noir können Funktionen nur am Ende einen Wert zurückgeben (es gibt kein vorzeitiges Zurückgeben). Um den Rückgabewert anzugeben, werten Sie ihn direkt vor der schließenden Klammer der Funktion aus.

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

Diese Funktion wandelt das Konten-Array in ein Field-Array um, das als Eingabe für einen Petersen-Hash verwendet werden kann.

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

So geben Sie eine veränderbare Variable an, also keine Konstante. Variablen in Noir müssen immer einen Wert haben, daher initialisieren wir diese Variable mit lauter Nullen.

1 for i in 0..ACCOUNT_NUMBER {

Dies ist eine for-Schleife. Beachten Sie, dass die Grenzen Konstanten sind. Bei Noir-Schleifen müssen die Grenzen zur Kompilierzeit bekannt sein. Der Grund dafür ist, dass arithmetische Schaltkreise keine Flusskontrolle unterstützen. Bei der Verarbeitung einer for-Schleife fügt der Compiler den Code darin einfach mehrmals ein, einmal für jede Iteration.

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}
Alle anzeigen

Schließlich sind wir bei der Funktion angelangt, die das Konten-Array hasht.

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 }

Diese Funktion findet das Konto mit einer bestimmten Adresse. Diese Funktion wäre in Standardcode furchtbar ineffizient, da sie über alle Konten iteriert, selbst nachdem sie die Adresse gefunden hat.

In Zero-Knowledge-Beweisen gibt es jedoch keine Flusskontrolle. Wenn wir jemals eine Bedingung überprüfen müssen, müssen wir sie jedes Mal überprüfen.

Ähnliches passiert bei if-Anweisungen. Die if-Anweisung in der obigen Schleife wird in diese mathematischen Anweisungen übersetzt.

conditionresult = accounts[i].address == address // eins, wenn sie gleich sind, andernfalls null

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

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

Die Funktion assert (opens in a new tab) führt dazu, dass der Zero-Knowledge-Beweis abstürzt, wenn die Behauptung falsch ist. In diesem Fall, wenn wir kein Konto mit der entsprechenden Adresse finden können. Um die Adresse zu melden, verwenden wir einen Format-String (opens in a new tab).

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

Diese Funktion wendet eine Überweisungstransaktion an und gibt das neue Konten-Array zurück.

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

Wir können in Noir nicht auf Strukturelemente innerhalb eines Format-Strings zugreifen, daher erstellen wir eine nutzbare Kopie.

1 assert (accounts[from].balance >= txn.amount,
2 f"{txnFrom} does not have {txnAmount} finney");
3
4 assert (accounts[from].nonce == txn.nonce,
5 f"Transaction has nonce {txnNonce}, but the account is expected to use {accountNonce}");

Dies sind zwei Bedingungen, die eine Transaktion ungültig machen könnten.

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}

Erstellen Sie das neue Konten-Array und geben Sie es dann zurück.

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

Diese Funktion liest die Adresse aus der Nachricht.

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

Die Adresse ist immer 20 Bytes (also 40 hexadezimale Ziffern) lang und beginnt bei Zeichen Nr. 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)
Alle anzeigen

Lesen Sie den Betrag und die Nonce aus der Nachricht.

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;

In der Nachricht ist die erste Zahl nach der Adresse der Betrag in Finney (also Tausendstel eines ETH), der überwiesen werden soll. Die zweite Zahl ist die Nonce. Jeglicher Text dazwischen wird ignoriert.

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 { // Wir haben es gerade gefunden
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}
Alle anzeigen

Die Rückgabe eines Tupels (opens in a new tab) ist die Noir-Methode, um mehrere Werte aus einer Funktion zurückzugeben.

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}
Alle anzeigen

Diese Funktion konvertiert die Nachricht in Bytes und konvertiert dann die Beträge in eine TransferTxn.

1// Das Äquivalent zu Viems hashMessage
2// https://viem.sh/docs/utilities/hashMessage#hashmessage
3fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {

Wir konnten den Pedersen-Hash für die Konten verwenden, da sie nur innerhalb des Zero-Knowledge-Beweises gehasht werden. In diesem Code müssen wir jedoch die Signatur der Nachricht überprüfen, die vom Browser generiert wird. Dafür müssen wir dem Ethereum-Signaturformat in EIP 191 (opens in a new tab) folgen. Das bedeutet, wir müssen einen kombinierten Puffer mit einem Standardpräfix, der Nachrichtenlänge in ASCII und der Nachricht selbst erstellen und den Ethereum-Standard keccak256 verwenden, um ihn zu hashen.

1 // ASCII-Präfix
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 ];
Alle anzeigen

Um Fälle zu vermeiden, in denen eine Anwendung den Benutzer auffordert, eine Nachricht zu signieren, die als Transaktion oder für einen anderen Zweck verwendet werden kann, legt EIP 191 fest, dass alle signierten Nachrichten mit dem Zeichen 0x19 (kein gültiges ASCII-Zeichen) beginnen, gefolgt von Ethereum Signed Message: und einem Zeilenumbruch.

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, "Messages whose length is over three digits are not supported");
Alle anzeigen

Behandeln Sie Nachrichtenlängen bis zu 999 und schlagen Sie fehl, wenn sie größer sind. Ich habe diesen Code hinzugefügt, obwohl die Nachrichtenlänge eine Konstante ist, weil es so einfacher ist, sie zu ändern. In einem Produktionssystem würden Sie wahrscheinlich aus Gründen der besseren Leistung einfach davon ausgehen, dass sich MESSAGE_LENGTH nicht ändert.

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

Verwenden Sie die Ethereum-Standardfunktion 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, erste 16 Bytes des Hashs, letzte 16 Bytes des Hashs
7{

Diese Funktion verifiziert die Signatur, was den Nachrichten-Hash erfordert. Sie liefert uns dann die Adresse, die sie signiert hat, und den Nachrichten-Hash. Der Nachrichten-Hash wird in zwei Field-Werten geliefert, da diese im Rest des Programms einfacher zu verwenden sind als ein Byte-Array.

Wir müssen zwei Field-Werte verwenden, da Feldberechnungen modulo (opens in a new tab) einer großen Zahl durchgeführt werden, diese Zahl jedoch typischerweise kleiner als 256 Bits (andernfalls wäre es schwierig, diese Berechnungen in der EVM durchzuführen).

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 }

Geben Sie hash1 und hash2 als veränderbare Variablen an und schreiben Sie den Hash Byte für Byte in sie hinein.

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

Dies ähnelt Soliditys ecrecover (opens in a new tab), mit zwei wichtigen Unterschieden:

  • Wenn die Signatur nicht gültig ist, schlägt der Aufruf bei einem assert fehl und das Programm wird abgebrochen.
  • Während der Public-Key aus der Signatur und dem Hash wiederhergestellt werden kann, ist dies eine Verarbeitung, die extern durchgeführt werden kann und sich daher nicht lohnt, innerhalb des Zero-Knowledge-Beweises durchgeführt zu werden. Wenn jemand versucht, uns hier zu betrügen, wird die Signaturverifizierung fehlschlagen.
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, // Hash des alten Konten-Arrays
14 Field, // Hash des neuen Konten-Arrays
15 Field, // Erste 16 Bytes des Nachrichten-Hashs
16 Field, // Letzte 16 Bytes des Nachrichten-Hashs
17 )
Alle anzeigen

Schließlich erreichen wir die Funktion main. Wir müssen beweisen, dass wir eine Transaktion haben, die den Hash der Konten gültig vom alten Wert auf den neuen ändert. Wir müssen auch beweisen, dass sie diesen spezifischen Transaktions-Hash hat, damit die Person, die sie gesendet hat, weiß, dass ihre Transaktion verarbeitet wurde.

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

Wir benötigen txn als veränderbar, da wir die Absenderadresse nicht aus der Nachricht lesen, sondern aus der Signatur.

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}
Alle anzeigen

Phase 2 - Hinzufügen eines Servers

In der zweiten Phase fügen wir einen Server hinzu, der Überweisungstransaktionen vom Browser empfängt und implementiert.

Um es in Aktion zu sehen:

  1. Stoppen Sie Vite, falls es läuft.

  2. Laden Sie den Branch herunter, der den Server enthält, und stellen Sie sicher, dass Sie alle erforderlichen Module haben.

    1git checkout 02-add-server
    2cd client
    3npm install
    4cd ../server
    5npm install
1
2 Es ist nicht nötig, den Noir-Code zu kompilieren, es ist derselbe Code, den Sie für Phase 1 verwendet haben.
3
43. Starten Sie den Server.
5
6 ```sh
7 npm run start
  1. Führen Sie in einem separaten Befehlszeilenfenster Vite aus, um den Browser-Code bereitzustellen.

    1cd client
    2npm run dev
1
25. Navigieren Sie zum Client-Code unter [http://localhost:5173](http://localhost:5173)
3
46. Bevor Sie eine Transaktion ausgeben können, müssen Sie die Nonce sowie den Betrag kennen, den Sie senden können. Um diese Informationen zu erhalten, klicken Sie auf **Update account data** und signieren Sie die Nachricht.
5
6 Wir haben hier ein Dilemma. Einerseits möchten wir keine Nachricht signieren, die wiederverwendet werden kann (ein [Replay-Angriff](https://en.wikipedia.org/wiki/Replay_attack)), weshalb wir überhaupt erst eine Nonce wollen. Wir haben jedoch noch keine Nonce. Die Lösung besteht darin, eine Nonce zu wählen, die nur einmal verwendet werden kann und die wir bereits auf beiden Seiten haben, wie z. B. die aktuelle Uhrzeit.
7
8 Das Problem bei dieser Lösung ist, dass die Zeit möglicherweise nicht perfekt synchronisiert ist. Stattdessen signieren wir also einen Wert, der sich jede Minute ändert. Das bedeutet, dass unser Zeitfenster für die Anfälligkeit gegenüber Replay-Angriffen höchstens eine Minute beträgt. In Anbetracht der Tatsache, dass die signierte Anfrage in der Produktion durch TLS geschützt ist und dass die andere Seite des Tunnels – der Server – das Guthaben und die Nonce bereits offenlegen kann (er muss sie kennen, um zu funktionieren), ist dies ein akzeptables Risiko.
9
107. Sobald der Browser das Guthaben und die Nonce zurückerhält, zeigt er das Überweisungsformular an. Wählen Sie die Zieladresse und den Betrag aus und klicken Sie auf **Transfer**. Signieren Sie diese Anfrage.
11
128. Um die Überweisung zu sehen, klicken Sie entweder auf **Update account data** oder schauen Sie in das Fenster, in dem Sie den Server ausführen. Der Server protokolliert den Zustand jedes Mal, wenn er sich ändert.
13
Alle anzeigen

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)

1
2#### `server/index.mjs` \{#server-index-mjs-1\}
3
4[Diese Datei](https://github.com/qbzzt/250911-zk-bank/blob/02-add-server/server/index.mjs) enthält den Serverprozess und interagiert mit dem Noir-Code in [`main.nr`](https://github.com/qbzzt/250911-zk-bank/blob/02-add-server/server/noir/src/main.nr). Hier ist eine Erklärung der interessanten Teile.
5
6```js
7import { Noir } from '@noir-lang/noir_js'

Die Bibliothek noir.js (opens in a new tab) bildet die Schnittstelle zwischen JavaScript-Code und Noir-Code.

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

Laden Sie den arithmetischen Schaltkreis – das kompilierte Noir-Programm, das wir in der vorherigen Phase erstellt haben – und bereiten Sie dessen Ausführung vor.

1// Wir stellen Kontoinformationen nur als Antwort auf eine signierte Anfrage zur Verfügung
2const accountInformation = async signature => {
3 const fromAddress = await recoverAddress({
4 hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),
5 signature
6 })

Um Kontoinformationen bereitzustellen, benötigen wir nur die Signatur. Der Grund dafür ist, dass wir bereits wissen, wie die Nachricht lauten wird, und somit auch den Nachrichten-Hash kennen.

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

Verarbeiten Sie eine Nachricht und führen Sie die darin codierte Transaktion aus.

1 // Öffentlichen Schlüssel abrufen
2 const pubKey = await recoverPublicKey({
3 hash,
4 signature
5 })

Da wir nun JavaScript auf dem Server ausführen, können wir den Public-Key dort abrufen, anstatt auf dem 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 })
Alle anzeigen

noir.execute führt das Noir-Programm aus. Die Parameter entsprechen denen, die in Prover.toml (opens in a new tab) angegeben sind. Beachten Sie, dass lange Werte als Array von Hexadezimalzeichenfolgen (["0x60", "0xA7"]) bereitgestellt werden, nicht als einzelner Hexadezimalwert (0x60A7), wie es Viem tut.

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

Wenn ein Fehler auftritt, fangen Sie ihn ab und leiten Sie dann eine vereinfachte Version an den Client weiter.

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

Wenden Sie die Transaktion an. Wir haben dies bereits im Noir-Code getan, aber es ist einfacher, es hier noch einmal zu tun, als das Ergebnis von dort zu extrahieren.

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

Die anfängliche Accounts-Struktur.

Phase 3 - Ethereum Smart Contracts

  1. Stoppen Sie die Server- und Client-Prozesse.

  2. Laden Sie den Branch mit den Smart Contracts herunter und stellen Sie sicher, dass Sie alle erforderlichen Module haben.

    1git checkout 03-smart-contracts
    2cd client
    3npm install
    4cd ../server
    5npm install
1
23. Führen Sie `anvil` in einem separaten Befehlszeilenfenster aus.
3
44. Generieren Sie den Verifizierungsschlüssel und den Solidity-Verifizierer und kopieren Sie dann den Verifizierer-Code in das Solidity-Projekt.
5
6 ```sh
7 cd noir
8 bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak
9 bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol
10 cp target/Verifier.sol ../../smart-contracts/src
Alle anzeigen
  1. Gehen Sie zu den Smart Contracts und legen Sie die Umgebungsvariablen fest, um die anvil-Blockchain zu verwenden.

    1cd ../../smart-contracts
    2export ETH_RPC_URL=http://localhost:8545
    3ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
1
26. Stellen Sie `Verifier.sol` bereit und speichern Sie die Adresse in einer Umgebungsvariablen.
3
4 ```sh
5 VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'`
6 echo $VERIFIER_ADDRESS
  1. Stellen Sie den ZkBank-Vertrag bereit.

    1ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'`
    2echo $ZKBANK_ADDRESS
1
2 Der Wert `0x199..67b` ist der Pederson-Hash des Anfangszustands von `Accounts`. Wenn Sie diesen Anfangszustand in `server/index.mjs` ändern, können Sie eine Transaktion ausführen, um den anfänglichen Hash zu sehen, der vom Zero-Knowledge-Beweis gemeldet wird.
3
48. Starten Sie den Server.
5
6 ```sh
7 cd ../server
8 npm run start
  1. Führen Sie den Client in einem anderen Befehlszeilenfenster aus.

    1cd client
    2npm run dev
1
210. Führen Sie einige Transaktionen aus.
3
411. Um zu überprüfen, ob sich der Zustand auf der Blockchain geändert hat, starten Sie den Serverprozess neu. Sie werden sehen, dass `ZkBank` keine Transaktionen mehr akzeptiert, da der ursprüngliche Hash-Wert in den Transaktionen von dem auf der Blockchain gespeicherten Hash-Wert abweicht.
5
6 Dies ist die Art von Fehler, die erwartet wird.
7

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

1
2#### `server/index.mjs` \{#server-index-mjs-2\}
3
4Die Änderungen in dieser Datei beziehen sich hauptsächlich auf die Erstellung des eigentlichen Beweises und dessen Einreichung auf der Blockchain.
5
6```js
7import { exec } from 'child_process'
8import util from 'util'
9
10const execPromise = util.promisify(exec)
Alle anzeigen

Wir müssen das Barretenberg-Paket (opens in a new tab) verwenden, um den eigentlichen Beweis zu erstellen, der auf der Blockchain gesendet werden soll. Wir können dieses Paket entweder durch Ausführen der Befehlszeilenschnittstelle (bb) oder durch Verwendung der JavaScript-Bibliothek bb.js (opens in a new tab) verwenden. Die JavaScript-Bibliothek ist viel langsamer als die native Ausführung von Code, daher verwenden wir hier exec (opens in a new tab), um die Befehlszeile zu nutzen.

Beachten Sie, dass Sie, wenn Sie sich für die Verwendung von bb.js entscheiden, eine Version verwenden müssen, die mit der von Ihnen verwendeten Noir-Version kompatibel ist. Zum Zeitpunkt des Schreibens verwendet die aktuelle Noir-Version (1.0.0-beta.11) die bb.js-Version 0.87.

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

Die Adresse hier ist diejenige, die Sie erhalten, wenn Sie mit einem sauberen anvil beginnen und den obigen Anweisungen folgen.

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

Dieser Private-Key gehört zu einem der standardmäßig vorfinanzierten Konten in anvil.

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

Generieren Sie einen Beweis mit der ausführbaren Datei bb.

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

Schreiben Sie den Witness in eine Datei.

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

Erstellen Sie den eigentlichen Beweis. Dieser Schritt erstellt auch eine Datei mit den öffentlichen Variablen, aber die benötigen wir nicht. Wir haben diese Variablen bereits von noir.execute erhalten.

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

Der Beweis ist ein JSON-Array von Field-Werten, die jeweils als Hexadezimalwert dargestellt werden. Wir müssen ihn jedoch in der Transaktion als einzelnen bytes-Wert senden, den Viem durch eine große Hexadezimalzeichenfolge darstellt. Hier ändern wir das Format, indem wir alle Werte verketten, alle 0x entfernen und dann am Ende eines hinzufügen.

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

Bereinigen und den Beweis zurückgeben.

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

Die öffentlichen Felder müssen ein Array von 32-Byte-Werten sein. Da wir jedoch den Transaktions-Hash auf zwei Field-Werte aufteilen mussten, erscheint er als 16-Byte-Wert. Hier fügen wir Nullen hinzu, damit Viem versteht, dass es sich tatsächlich um 32 Bytes handelt.

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

Jede Adresse verwendet jede Nonce nur einmal, sodass wir eine Kombination aus fromAddress und nonce als eindeutige Kennung für die Witness-Datei und das Ausgabeverzeichnis verwenden können.

1 try {
2 await zkBank.write.processTransaction([
3 proof, publicFields])
4 } catch (err) {
5 console.log(`Verification error: ${err}`)
6 throw Error("Can't verify the transaction onchain")
7 }
8 .
9 .
10 .
11}
Alle anzeigen

Senden Sie die Transaktion an die Blockchain.

smart-contracts/src/ZkBank.sol

Dies ist der Onchain-Code, der die Transaktion empfängt.

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 }
Alle anzeigen

Der Onchain-Code muss zwei Variablen verfolgen: den Verifizierer (ein separater Vertrag, der von nargo erstellt wird) und den aktuellen Zustands-Hash.

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

Jedes Mal, wenn sich der Zustand ändert, geben wir ein TransactionProcessed-Ereignis aus.

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

Diese Funktion verarbeitet Transaktionen. Sie erhält den Beweis (als bytes) und die öffentlichen Eingaben (als bytes32-Array) in dem Format, das der Verifizierer benötigt (um die Onchain-Verarbeitung und damit die Gaskosten zu minimieren).

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

Der Zero-Knowledge-Beweis muss belegen, dass die Transaktion von unserem aktuellen Hash zu einem neuen wechselt.

1 myVerifier.verify(_proof, _publicFields);

Rufen Sie den Verifizierer-Vertrag auf, um den Zero-Knowledge-Beweis zu verifizieren. Dieser Schritt macht die Transaktion rückgängig, wenn der Zero-Knowledge-Beweis falsch ist.

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

Wenn alles in Ordnung ist, aktualisieren Sie den Zustands-Hash auf den neuen Wert und geben Sie ein TransactionProcessed-Ereignis aus.

Missbrauch durch die zentralisierte Komponente

Informationssicherheit besteht aus drei Attributen:

  • Vertraulichkeit, Benutzer können keine Informationen lesen, für die sie nicht autorisiert sind.
  • Integrität, Informationen können nur von autorisierten Benutzern auf autorisierte Weise geändert werden.
  • Verfügbarkeit, autorisierte Benutzer können das System nutzen.

In diesem System wird die Integrität durch Zero-Knowledge-Beweise gewährleistet. Die Verfügbarkeit ist viel schwerer zu garantieren, und Vertraulichkeit ist unmöglich, da die Bank den Kontostand jedes Kontos und alle Transaktionen kennen muss. Es gibt keine Möglichkeit, eine Entität, die über Informationen verfügt, daran zu hindern, diese Informationen weiterzugeben.

Es könnte möglich sein, eine wirklich vertrauliche Bank unter Verwendung von Stealth-Adressen (opens in a new tab) zu erstellen, aber das sprengt den Rahmen dieses Artikels.

Falsche Informationen

Eine Möglichkeit, wie der Server die Integrität verletzen kann, besteht darin, falsche Informationen bereitzustellen, wenn Daten angefordert werden (opens in a new tab).

Um dies zu lösen, können wir ein zweites Noir-Programm schreiben, das die Konten als private Eingabe und die Adresse, für die Informationen angefordert werden, als öffentliche Eingabe erhält. Die Ausgabe ist das Guthaben und die Nonce dieser Adresse sowie der Hash der Konten.

Natürlich kann dieser Beweis nicht auf der Blockchain verifiziert werden, da wir Nonces und Guthaben nicht auf der Blockchain veröffentlichen möchten. Er kann jedoch durch den im Browser ausgeführten Client-Code verifiziert werden.

Erzwungene Transaktionen

Der übliche Mechanismus zur Gewährleistung der Verfügbarkeit und zur Verhinderung von Zensur auf L2s sind erzwungene Transaktionen (opens in a new tab). Aber erzwungene Transaktionen lassen sich nicht mit Zero-Knowledge-Beweisen kombinieren. Der Server ist die einzige Entität, die Transaktionen verifizieren kann.

Wir können smart-contracts/src/ZkBank.sol so ändern, dass erzwungene Transaktionen akzeptiert werden und der Server daran gehindert wird, den Zustand zu ändern, bis sie verarbeitet sind. Dies öffnet uns jedoch für einen einfachen Denial-of-Service-Angriff. Was ist, wenn eine erzwungene Transaktion ungültig und daher unmöglich zu verarbeiten ist?

Die Lösung besteht darin, einen Zero-Knowledge-Beweis dafür zu haben, dass eine erzwungene Transaktion ungültig ist. Dies gibt dem Server drei Optionen:

  • Die erzwungene Transaktion verarbeiten und einen Zero-Knowledge-Beweis dafür liefern, dass sie verarbeitet wurde, sowie den neuen Zustands-Hash.
  • Die erzwungene Transaktion ablehnen und dem Vertrag einen Zero-Knowledge-Beweis dafür liefern, dass die Transaktion ungültig ist (unbekannte Adresse, falsche Nonce oder unzureichendes Guthaben).
  • Die erzwungene Transaktion ignorieren. Es gibt keine Möglichkeit, den Server zu zwingen, die Transaktion tatsächlich zu verarbeiten, aber das bedeutet, dass das gesamte System nicht verfügbar ist.

Verfügbarkeitskautionen

In einer realen Implementierung gäbe es wahrscheinlich eine Art Profitmotiv, um den Server am Laufen zu halten. Wir können diesen Anreiz verstärken, indem wir den Server eine Verfügbarkeitskaution hinterlegen lassen, die jeder verbrennen kann, wenn eine erzwungene Transaktion nicht innerhalb eines bestimmten Zeitraums verarbeitet wird.

Schlechter Noir-Code

Normalerweise laden wir den Quellcode in eine Blocksuchmaschine (opens in a new tab) hoch, um das Vertrauen der Leute in einen Smart Contract zu gewinnen. Im Falle von Zero-Knowledge-Beweisen ist das jedoch unzureichend.

Verifier.sol enthält den Verifizierungsschlüssel, der eine Funktion des Noir-Programms ist. Dieser Schlüssel sagt uns jedoch nicht, was das Noir-Programm war. Um tatsächlich eine vertrauenswürdige Lösung zu haben, müssen Sie das Noir-Programm (und die Version, die es erstellt hat) hochladen. Andernfalls könnten die Zero-Knowledge-Beweise ein anderes Programm widerspiegeln, eines mit einer Hintertür.

Bis Blocksuchmaschinen es uns ermöglichen, Noir-Programme hochzuladen und zu verifizieren, sollten Sie dies selbst tun (vorzugsweise auf IPFS). Dann können erfahrene Benutzer den Quellcode herunterladen, ihn selbst kompilieren, Verifier.sol erstellen und überprüfen, ob er mit dem auf der Blockchain identisch ist.

Fazit

Plasma-artige Anwendungen erfordern eine zentralisierte Komponente als Informationsspeicher. Dies eröffnet potenzielle Schwachstellen, ermöglicht es uns aber im Gegenzug, die Privatsphäre auf eine Weise zu wahren, die auf der Blockchain selbst nicht verfügbar ist. Mit Zero-Knowledge-Beweisen können wir die Integrität sicherstellen und es möglicherweise für denjenigen, der die zentralisierte Komponente betreibt, wirtschaftlich vorteilhaft machen, die Verfügbarkeit aufrechtzuerhalten.

Weitere meiner Arbeiten finden Sie hier (opens in a new tab).

Danksagungen

  • Josh Crites hat einen Entwurf dieses Artikels gelesen und mir bei einem kniffligen Noir-Problem geholfen.

Alle verbleibenden Fehler liegen in meiner Verantwortung.

Letzte Aktualisierung der Seite: 28. Oktober 2025

War dieses Tutorial hilfreich?