Przejdź do głównej treści

Napisz specyficzną dla aplikacji sieć Plasma, która chroni prywatność

wiedza zerowa
serwer
pozałańcuchowy
prywatność
Zaawansowany
Ori Pomerantz
15 października 2025
29 minut czytania

Wprowadzenie

W przeciwieństwie do rollupów, sieci Plasma wykorzystują sieć główną Ethereum do zapewnienia integralności, ale nie dostępności. W tym artykule napiszemy aplikację, która zachowuje się jak Plasma, gdzie Ethereum gwarantuje integralność (brak nieautoryzowanych zmian), ale nie dostępność (scentralizowany komponent może ulec awarii i unieruchomić cały system).

Aplikacja, którą tu napiszemy, to bank chroniący prywatność. Różne adresy posiadają konta z saldami i mogą wysyłać pieniądze (ETH) na inne konta. Bank publikuje hashe stanu (kont i ich sald) oraz transakcji, ale przechowuje rzeczywiste salda pozałańcuchowo (offchain), gdzie mogą pozostać prywatne.

Projekt

Nie jest to system gotowy do wdrożenia produkcyjnego, lecz narzędzie edukacyjne. Jako takie, zostało napisane z kilkoma upraszczającymi założeniami.

  • Stała pula kont. Istnieje określona liczba kont, a każde konto należy do z góry określonego adresu. Czyni to system znacznie prostszym, ponieważ w dowodach z wiedzą zerową trudno jest obsługiwać struktury danych o zmiennym rozmiarze. W systemie gotowym do wdrożenia produkcyjnego możemy użyć korzenia drzewa Merklego jako hasha stanu i dostarczać dowody Merklego dla wymaganych sald.

  • Przechowywanie w pamięci. W systemie produkcyjnym musimy zapisywać wszystkie salda kont na dysku, aby zachować je w przypadku restartu. Tutaj jest w porządku, jeśli informacje po prostu zostaną utracone.

  • Tylko transfery. System produkcyjny wymagałby sposobu na deponowanie aktywów w banku i ich wypłacanie. Jednak celem tutaj jest jedynie zilustrowanie koncepcji, więc ten bank ogranicza się do transferów.

Dowody z wiedzą zerową

Na podstawowym poziomie dowód z wiedzą zerową pokazuje, że prover zna pewne dane, Daneprywatne, takie, że istnieje relacja Relacja między pewnymi danymi publicznymi, Danepubliczne, a Daneprywatne. Weryfikator zna Relację i Danepubliczne.

Aby zachować prywatność, stany i transakcje muszą być prywatne. Aby jednak zapewnić integralność, kryptograficzny hash (otwiera się w nowej karcie) stanów musi być publiczny. Aby udowodnić osobom przesyłającym transakcje, że te transakcje naprawdę miały miejsce, musimy również publikować hashe transakcji.

W większości przypadków Daneprywatne są wejściem do programu dowodu z wiedzą zerową, a Danepubliczne są wyjściem.

Te pola w Daneprywatne:

  • Stann, stary stan
  • Stann+1, nowy stan
  • Transakcja, transakcja, która zmienia stary stan na nowy. Transakcja ta musi zawierać następujące pola:
    • Adres docelowy, który otrzymuje transfer
    • Kwota, która jest transferowana
    • Nonce, aby upewnić się, że każda transakcja może zostać przetworzona tylko raz. Adres źródłowy nie musi znajdować się w transakcji, ponieważ można go odzyskać z podpisu.
  • Podpis, podpis upoważniony do wykonania transakcji. W naszym przypadku jedynym adresem upoważnionym do wykonania transakcji jest adres źródłowy. Ponieważ nasz system z wiedzą zerową działa w taki a nie inny sposób, oprócz podpisu Ethereum potrzebujemy również klucza publicznego konta.

Oto pola w Danepubliczne:

  • Hash(Stann) hash starego stanu
  • Hash(Stann+1) hash nowego stanu
  • Hash(Transakcja) hash transakcji, która zmienia stan ze Stann na Stann+1.

Relacja sprawdza kilka warunków:

  • Publiczne hashe są rzeczywiście poprawnymi hashami dla pól prywatnych.
  • Transakcja, po zastosowaniu do starego stanu, skutkuje nowym stanem.
  • Podpis pochodzi z adresu źródłowego transakcji.

Ze względu na właściwości kryptograficznych funkcji skrótu, udowodnienie tych warunków wystarczy, aby zapewnić integralność.

Struktury danych

Podstawową strukturą danych jest stan utrzymywany przez serwer. Dla każdego konta serwer śledzi saldo konta oraz nonce (otwiera się w nowej karcie), używane do zapobiegania atakom powtórzeniowym (replay attacks) (otwiera się w nowej karcie).

Komponenty

Ten system wymaga dwóch komponentów:

  • Serwer, który odbiera transakcje, przetwarza je i publikuje hashe w łańcuchu wraz z dowodami z wiedzą zerową.
  • Inteligentny kontrakt, który przechowuje hashe i weryfikuje dowody z wiedzą zerową, aby upewnić się, że przejścia stanów są prawidłowe.

Przepływ danych i sterowania

Oto sposoby, w jakie różne komponenty komunikują się w celu transferu z jednego konta na drugie.

  1. Przeglądarka internetowa przesyła podpisaną transakcję z prośbą o transfer z konta podpisującego na inne konto.

  2. Serwer weryfikuje, czy transakcja jest ważna:

    • Podpisujący posiada konto w banku z wystarczającym saldem.
    • Odbiorca posiada konto w banku.
  3. Serwer oblicza nowy stan, odejmując transferowaną kwotę od salda podpisującego i dodając ją do salda odbiorcy.

  4. Serwer oblicza dowód z wiedzą zerową, że zmiana stanu jest prawidłowa.

  5. Serwer przesyła do Ethereum transakcję, która zawiera:

    • Nowy hash stanu
    • Hash transakcji (aby nadawca transakcji wiedział, że została przetworzona)
    • Dowód z wiedzą zerową, który udowadnia, że przejście do nowego stanu jest prawidłowe
  6. Inteligentny kontrakt weryfikuje dowód z wiedzą zerową.

  7. Jeśli dowód z wiedzą zerową jest poprawny, inteligentny kontrakt wykonuje następujące czynności:

    • Aktualizuje obecny hash stanu na nowy hash stanu
    • Emituje wpis logu z nowym hashem stanu i hashem transakcji

Narzędzia

W przypadku kodu po stronie klienta użyjemy Vite (otwiera się w nowej karcie), React (otwiera się w nowej karcie), Viem (otwiera się w nowej karcie) i Wagmi (otwiera się w nowej karcie). Są to standardowe narzędzia w branży; jeśli nie jesteś z nimi zaznajomiony, możesz skorzystać z tego samouczka.

Większość serwera jest napisana w JavaScript przy użyciu Node (otwiera się w nowej karcie). Część dotycząca wiedzy zerowej jest napisana w języku Noir (otwiera się w nowej karcie). Potrzebujemy wersji 1.0.0-beta.10, więc po zainstalowaniu Noir zgodnie z instrukcjami (otwiera się w nowej karcie), uruchom:

noirup -v 1.0.0-beta.10

Blockchain, którego używamy, to anvil, lokalny testowy blockchain będący częścią Foundry (otwiera się w nowej karcie).

Implementacja

Ponieważ jest to złożony system, wdrożymy go etapami.

Etap 1 - Ręczna wiedza zerowa

W pierwszym etapie podpiszemy transakcję w przeglądarce, a następnie ręcznie dostarczymy informacje do dowodu z wiedzą zerową. Kod wiedzy zerowej oczekuje tych informacji w server/noir/Prover.toml (udokumentowane tutaj (otwiera się w nowej karcie)).

Aby zobaczyć to w akcji:

  1. Upewnij się, że masz zainstalowane Node (otwiera się w nowej karcie) i Noir (otwiera się w nowej karcie). Najlepiej zainstalować je w systemie UNIX, takim jak macOS, Linux lub WSL (otwiera się w nowej karcie).

  2. Pobierz kod etapu 1 i uruchom serwer WWW, aby serwować kod klienta.

    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
    

    Powodem, dla którego potrzebujesz tutaj serwera WWW, jest to, że aby zapobiec pewnym rodzajom oszustw, wiele portfeli (takich jak MetaMask) nie akceptuje plików serwowanych bezpośrednio z dysku.

  3. Otwórz przeglądarkę z portfelem.

  4. W portfelu wprowadź nową frazę odzyskiwania (passphrase). Zauważ, że to usunie twoją obecną frazę odzyskiwania, więc upewnij się, że masz kopię zapasową.

    Fraza odzyskiwania to test test test test test test test test test test test junk, domyślna testowa fraza dla anvil.

  5. Przejdź do kodu po stronie klienta (otwiera się w nowej karcie).

  6. Połącz się z portfelem i wybierz konto docelowe oraz kwotę.

  7. Kliknij Sign (Podpisz) i podpisz transakcję.

  8. Pod nagłówkiem Prover.toml znajdziesz tekst. Zastąp server/noir/Prover.toml tym tekstem.

  9. Wykonaj dowód z wiedzą zerową.

    cd ../server/noir
    nargo execute
    

    Wynik powinien być podobny do

    ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute
    
    [zkBank] Circuit witness successfully solved
    [zkBank] Witness saved to target/zkBank.gz
    [zkBank] Circuit output: (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85)
    
  10. Porównaj dwie ostatnie wartości z hashem, który widzisz w przeglądarce internetowej, aby sprawdzić, czy wiadomość została poprawnie zhashowana.

server/noir/Prover.toml

Ten plik (otwiera się w nowej karcie) pokazuje format informacji oczekiwany przez Noir.

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

Wiadomość jest w formacie tekstowym, co ułatwia jej zrozumienie przez użytkownika (co jest konieczne podczas podpisywania) oraz parsowanie przez kod Noir. Kwota jest podana w finneyach, aby z jednej strony umożliwić transfery ułamkowe, a z drugiej być łatwo czytelną. Ostatnia liczba to nonce (otwiera się w nowej karcie).

Ciąg znaków ma długość 100 znaków. Dowody z wiedzą zerową nie radzą sobie dobrze z danymi o zmiennym rozmiarze, więc często konieczne jest dopełnianie danych (padding).

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

Te trzy parametry to tablice bajtów o stałym rozmiarze.

W ten sposób określa się tablicę struktur. Dla każdego wpisu określamy adres, saldo (w milliETH, czyli finney (otwiera się w nowej karcie)) oraz następną wartość nonce.

client/src/Transfer.tsx

Ten plik (otwiera się w nowej karcie) implementuje przetwarzanie po stronie klienta i generuje plik server/noir/Prover.toml (ten, który zawiera parametry wiedzy zerowej).

Oto wyjaśnienie ciekawszych części.

export default attrs =>  {

Ta funkcja tworzy komponent React Transfer, który inne pliki mogą importować.

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

Są to adresy kont, adresy utworzone przez frazę odzyskiwania test ... test junk. Jeśli chcesz użyć własnych adresów, po prostu zmodyfikuj tę definicję.

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

Te hooki Wagmi (otwiera się w nowej karcie) pozwalają nam na dostęp do biblioteki Viem (otwiera się w nowej karcie) i portfela.

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

To jest wiadomość, dopełniona spacjami. Za każdym razem, gdy zmienia się jedna ze zmiennych useState (otwiera się w nowej karcie), komponent jest rysowany ponownie, a message jest aktualizowany.

  const sign = async () => {

Ta funkcja jest wywoływana, gdy użytkownik kliknie przycisk Sign. Wiadomość jest automatycznie aktualizowana, ale podpis wymaga zatwierdzenia przez użytkownika w portfelu, a nie chcemy o to prosić, chyba że jest to konieczne.

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

Poproś portfel o podpisanie wiadomości (otwiera się w nowej karcie).

    const hash = hashMessage(message)

Pobierz hash wiadomości. Warto udostępnić go użytkownikowi w celu debugowania (kodu Noir).

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

Pobierz klucz publiczny (otwiera się w nowej karcie). Jest to wymagane dla funkcji Noir ecrecover (otwiera się w nowej karcie).

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

Ustaw zmienne stanu. Zrobienie tego powoduje ponowne narysowanie komponentu (po zakończeniu funkcji sign) i pokazuje użytkownikowi zaktualizowane wartości.

    let proverToml = `

Tekst dla Prover.toml.

message="${message}"

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

Viem dostarcza nam klucz publiczny jako 65-bajtowy ciąg szesnastkowy. Pierwszy bajt to 0x04, znacznik wersji. Następnie mamy 32 bajty dla wartości x klucza publicznego, a potem 32 bajty dla wartości y klucza publicznego.

Jednak Noir oczekuje otrzymania tych informacji jako dwóch tablic bajtów, jednej dla x i jednej dla y. Łatwiej jest to sparsować tutaj, po stronie klienta, niż jako część dowodu z wiedzą zerową.

Należy pamiętać, że jest to ogólnie dobra praktyka w dziedzinie wiedzy zerowej. Kod wewnątrz dowodu z wiedzą zerową jest kosztowny, więc wszelkie przetwarzanie, które można wykonać poza dowodem z wiedzą zerową, powinno być wykonywane poza nim.

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

Podpis jest również dostarczany jako 65-bajtowy ciąg szesnastkowy. Jednak ostatni bajt jest niezbędny tylko do odzyskania klucza publicznego. Ponieważ klucz publiczny zostanie już dostarczony do kodu Noir, nie potrzebujemy go do weryfikacji podpisu, a kod Noir go nie wymaga.

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

Dostarcz konta.

    setProverToml(proverToml)
  }

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

To jest format HTML (a dokładniej JSX (otwiera się w nowej karcie)) komponentu.

server/noir/src/main.nr

Ten plik (otwiera się w nowej karcie) to właściwy kod wiedzy zerowej.

use std::hash::pedersen_hash;

Hash Pedersena (otwiera się w nowej karcie) jest dostarczany z biblioteką standardową Noir (otwiera się w nowej karcie). Dowody z wiedzą zerową powszechnie używają tej funkcji skrótu. Jest ona o wiele łatwiejsza do obliczenia wewnątrz obwodów arytmetycznych (otwiera się w nowej karcie) w porównaniu ze standardowymi funkcjami skrótu.

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

Te dwie funkcje to zewnętrzne biblioteki, zdefiniowane w Nargo.toml (otwiera się w nowej karcie). Robią dokładnie to, na co wskazują ich nazwy: funkcja obliczająca hash keccak256 (otwiera się w nowej karcie) oraz funkcja, która weryfikuje podpisy Ethereum i odzyskuje adres Ethereum podpisującego.

global ACCOUNT_NUMBER : u32 = 5;

Noir jest inspirowany językiem Rust (otwiera się w nowej karcie). Zmienne domyślnie są stałymi. W ten sposób definiujemy globalne stałe konfiguracyjne. W szczególności ACCOUNT_NUMBER to liczba kont, które przechowujemy.

Typy danych o nazwie u<number> oznaczają tę liczbę bitów, bez znaku (unsigned). Jedyne obsługiwane typy to u8, u16, u32, u64 i u128.

global FLAT_ACCOUNT_FIELDS : u32 = 2;

Ta zmienna jest używana do hasha Pedersena kont, jak wyjaśniono poniżej.

global MESSAGE_LENGTH : u32 = 100;

Jak wyjaśniono powyżej, długość wiadomości jest stała. Została ona określona tutaj.

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

Podpisy EIP-191 (otwiera się w nowej karcie) wymagają bufora z 26-bajtowym prefiksem, po którym następuje długość wiadomości w ASCII, a na końcu sama wiadomość.

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

Informacje, które przechowujemy o koncie. Field (otwiera się w nowej karcie) to liczba, zazwyczaj do 253 bitów, która może być użyta bezpośrednio w obwodzie arytmetycznym (otwiera się w nowej karcie) implementującym dowód z wiedzą zerową. Tutaj używamy Field do przechowywania 160-bitowego adresu Ethereum.

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

Informacje, które przechowujemy dla transakcji transferu.

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

Definicja funkcji. Parametrem są informacje Account. Wynikiem jest tablica zmiennych Field, której długość to FLAT_ACCOUNT_FIELDS

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

Pierwsza wartość w tablicy to adres konta. Druga zawiera zarówno saldo, jak i nonce. Wywołania .into() zmieniają liczbę na wymagany typ danych. account.nonce to wartość u32, ale aby dodać ją do account.balance << 32, wartości u128, musi być typu u128. To jest pierwsze .into(). Drugie konwertuje wynik u128 na Field, aby pasował do tablicy.

    flat
}

W Noir funkcje mogą zwracać wartość tylko na końcu (nie ma wczesnego powrotu, ang. early return). Aby określić wartość zwracaną, obliczasz ją tuż przed nawiasem zamykającym funkcję.

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

Ta funkcja zamienia tablicę kont w tablicę Field, która może być użyta jako wejście do hasha Pedersena.

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

W ten sposób określasz zmienną modyfikowalną (mutable), czyli nie stałą. Zmienne w Noir muszą zawsze mieć wartość, więc inicjujemy tę zmienną samymi zerami.

    for i in 0..ACCOUNT_NUMBER {

To jest pętla for. Zauważ, że granice są stałymi. Pętle w Noir muszą mieć granice znane w czasie kompilacji. Powodem jest to, że obwody arytmetyczne nie obsługują sterowania przepływem. Podczas przetwarzania pętli for, kompilator po prostu umieszcza kod wewnątrz niej wielokrotnie, raz dla każdej iteracji.

Wreszcie dotarliśmy do funkcji, która hashuje tablicę kont.

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

Ta funkcja znajduje konto o określonym adresie. Funkcja ta byłaby strasznie nieefektywna w standardowym kodzie, ponieważ iteruje po wszystkich kontach, nawet po znalezieniu adresu.

Jednak w dowodach z wiedzą zerową nie ma sterowania przepływem. Jeśli kiedykolwiek musimy sprawdzić warunek, musimy go sprawdzać za każdym razem.

Podobna rzecz dzieje się z instrukcjami if. Instrukcja if w powyższej pętli jest tłumaczona na te instrukcje matematyczne.

wynikwarunku = accounts[i].address == address // jeden, jeśli są równe, w przeciwnym razie zero

kontonowe = wynikwarunku*i + (1-wynikwarunku)*kontostare

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

    account
}

Funkcja assert (otwiera się w nowej karcie) powoduje awarię dowodu z wiedzą zerową, jeśli asercja jest fałszywa. W tym przypadku, jeśli nie możemy znaleźć konta z odpowiednim adresem. Aby zgłosić adres, używamy ciągu formatującego (otwiera się w nowej karcie).

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

Ta funkcja stosuje transakcję transferu i zwraca nową tablicę kont.

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

Nie możemy uzyskać dostępu do elementów struktury wewnątrz ciągu formatującego w Noir, więc tworzymy użyteczną kopię.

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

Są to dwa warunki, które mogłyby sprawić, że transakcja będzie nieważna.

    let mut newAccounts = accounts;

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

    newAccounts
}

Utwórz nową tablicę kont i następnie ją zwróć.

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

Ta funkcja odczytuje adres z wiadomości.

{
    let mut result : Field = 0;

    for i in 7..47 {

Adres ma zawsze długość 20 bajtów (czyli 40 cyfr szesnastkowych) i zaczyna się od 7. znaku.

Odczytaj kwotę i nonce z wiadomości.

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

W wiadomości pierwsza liczba po adresie to kwota finney (czyli tysięcznych części ETH) do transferu. Druga liczba to nonce. Dowolny tekst między nimi jest ignorowany.

Zwracanie krotki (tuple) (otwiera się w nowej karcie) to sposób Noir na zwrócenie wielu wartości z funkcji.

Ta funkcja konwertuje wiadomość na bajty, a następnie konwertuje kwoty na TransferTxn.

// Odpowiednik hashMessage z Viem
// https://viem.sh/docs/utilities/hashMessage#hashmessage
fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {

Byliśmy w stanie użyć hasha Pedersena dla kont, ponieważ są one hashowane tylko wewnątrz dowodu z wiedzą zerową. Jednak w tym kodzie musimy sprawdzić podpis wiadomości, który jest generowany przez przeglądarkę. W tym celu musimy postępować zgodnie z formatem podpisywania Ethereum w EIP-191 (otwiera się w nowej karcie). Oznacza to, że musimy utworzyć połączony bufor ze standardowym prefiksem, długością wiadomości w ASCII i samą wiadomością, a następnie użyć standardowego dla Ethereum keccak256, aby go zhashować.

Aby uniknąć przypadków, w których aplikacja prosi użytkownika o podpisanie wiadomości, która może zostać użyta jako transakcja lub w innym celu, EIP-191 określa, że wszystkie podpisane wiadomości zaczynają się od znaku 0x19 (nie jest to prawidłowy znak ASCII), po którym następuje Ethereum Signed Message: i znak nowej linii.

Obsłuż długości wiadomości do 999 i zgłoś błąd, jeśli jest większa. Dodałem ten kod, mimo że długość wiadomości jest stała, ponieważ ułatwia to jej zmianę. W systemie produkcyjnym prawdopodobnie założyłbyś po prostu, że MESSAGE_LENGTH nie ulega zmianie w celu uzyskania lepszej wydajności.

    keccak256::keccak256(buffer, HASH_BUFFER_SIZE)
}

Użyj standardowej dla Ethereum funkcji keccak256.

fn signatureToAddressAndHash(
        message: str<MESSAGE_LENGTH>, 
        pubKeyX: [u8; 32],
        pubKeyY: [u8; 32],
        signature: [u8; 64]
    ) -> (Field, Field, Field)   // adres, pierwsze 16 bajtów hasha, ostatnie 16 bajtów hasha        
{

Ta funkcja weryfikuje podpis, co wymaga hasha wiadomości. Następnie dostarcza nam adres, który go podpisał, oraz hash wiadomości. Hash wiadomości jest dostarczany w dwóch wartościach Field, ponieważ są one łatwiejsze do użycia w reszcie programu niż tablica bajtów.

Musimy użyć dwóch wartości Field, ponieważ obliczenia na ciałach (fields) są wykonywane modulo (otwiera się w nowej karcie) duża liczba, ale ta liczba ma zazwyczaj mniej niż 256 bitów (w przeciwnym razie trudno byłoby wykonać te obliczenia w 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();
    }

Określ hash1 i hash2 jako zmienne modyfikowalne i zapisz w nich hash bajt po bajcie.

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

Jest to podobne do ecrecover w Solidity (otwiera się w nowej karcie), z dwiema ważnymi różnicami:

  • Jeśli podpis jest nieważny, wywołanie kończy się błędem assert i program zostaje przerwany.
  • Podczas gdy klucz publiczny można odzyskać z podpisu i hasha, jest to przetwarzanie, które można wykonać zewnętrznie i dlatego nie warto go wykonywać wewnątrz dowodu z wiedzą zerową. Jeśli ktoś spróbuje nas tu oszukać, weryfikacja podpisu się nie powiedzie.

Wreszcie docieramy do funkcji main. Musimy udowodnić, że mamy transakcję, która w prawidłowy sposób zmienia hash kont ze starej wartości na nową. Musimy również udowodnić, że ma ona ten konkretny hash transakcji, aby osoba, która ją wysłała, wiedziała, że jej transakcja została przetworzona.

{
    let mut txn = readTransferTxn(message);

Potrzebujemy, aby txn było modyfikowalne, ponieważ nie odczytujemy adresu nadawcy (from) z wiadomości, odczytujemy go z podpisu.

Etap 2 - Dodanie serwera

W drugim etapie dodajemy serwer, który odbiera i implementuje transakcje transferu z przeglądarki.

Aby zobaczyć to w akcji:

  1. Zatrzymaj Vite, jeśli jest uruchomione.

  2. Pobierz gałąź, która zawiera serwer i upewnij się, że masz wszystkie niezbędne moduły.

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

    Nie ma potrzeby kompilowania kodu Noir, jest to ten sam kod, którego użyłeś w etapie 1.

  3. Uruchom serwer.

    npm run start
    
  4. W osobnym oknie wiersza poleceń uruchom Vite, aby serwować kod przeglądarki.

    cd client
    npm run dev
    
  5. Przejdź do kodu klienta pod adresem http://localhost:5173 (otwiera się w nowej karcie)

  6. Zanim będziesz mógł wydać transakcję, musisz znać nonce, a także kwotę, którą możesz wysłać. Aby uzyskać te informacje, kliknij Update account data (Aktualizuj dane konta) i podpisz wiadomość.

    Mamy tu dylemat. Z jednej strony nie chcemy podpisywać wiadomości, która może zostać użyta ponownie (atak powtórzeniowy (otwiera się w nowej karcie)), dlatego w ogóle chcemy mieć nonce. Jednak nie mamy jeszcze nonce. Rozwiązaniem jest wybranie nonce, które może zostać użyte tylko raz i które już mamy po obu stronach, takie jak aktualny czas.

    Problemem z tym rozwiązaniem jest to, że czas może nie być idealnie zsynchronizowany. Dlatego zamiast tego podpisujemy wartość, która zmienia się co minutę. Oznacza to, że nasze okno podatności na ataki powtórzeniowe wynosi co najwyżej jedną minutę. Biorąc pod uwagę, że w produkcji podpisane żądanie będzie chronione przez TLS, a druga strona tunelu---serwer---może już ujawnić saldo i nonce (musi je znać, aby działać), jest to akceptowalne ryzyko.

  7. Gdy przeglądarka otrzyma z powrotem saldo i nonce, wyświetli formularz transferu. Wybierz adres docelowy i kwotę, a następnie kliknij Transfer. Podpisz to żądanie.

  8. Aby zobaczyć transfer, kliknij Update account data lub spójrz w okno, w którym uruchomiłeś serwer. Serwer loguje stan za każdym razem, gdy ulega on zmianie.

server/index.mjs

Ten plik (otwiera się w nowej karcie) zawiera proces serwera i wchodzi w interakcję z kodem Noir w main.nr (otwiera się w nowej karcie). Oto wyjaśnienie ciekawszych części.

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

Biblioteka noir.js (otwiera się w nowej karcie) stanowi interfejs między kodem JavaScript a kodem Noir.

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

Załaduj obwód arytmetyczny---skompilowany program Noir, który stworzyliśmy w poprzednim etapie---i przygotuj go do wykonania.

// Udostępniamy informacje o koncie tylko w odpowiedzi na podpisane żądanie
const accountInformation = async signature => {
    const fromAddress = await recoverAddress({
        hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),
        signature
    })

Aby dostarczyć informacje o koncie, potrzebujemy tylko podpisu. Powodem jest to, że już wiemy, jaka będzie wiadomość, a co za tym idzie hash wiadomości.

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

Przetwórz wiadomość i wykonaj transakcję, którą koduje.

    // Pobierz klucz publiczny
    const pubKey = await recoverPublicKey({
        hash,
        signature
    })

Teraz, gdy uruchamiamy JavaScript na serwerze, możemy pobrać klucz publiczny tam, zamiast na kliencie.

noir.execute uruchamia program Noir. Parametry są równoważne tym podanym w Prover.toml (otwiera się w nowej karcie). Zauważ, że długie wartości są podawane jako tablica ciągów szesnastkowych (["0x60", "0xA7"]), a nie jako pojedyncza wartość szesnastkowa (0x60A7), jak robi to Viem.

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

Jeśli wystąpi błąd, przechwyć go, a następnie przekaż uproszczoną wersję do klienta.

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

Zastosuj transakcję. Zrobiliśmy to już w kodzie Noir, ale łatwiej jest to zrobić ponownie tutaj, niż wyodrębniać stamtąd wynik.

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

Początkowa struktura Accounts.

Etap 3 - Inteligentne kontrakty Ethereum

  1. Zatrzymaj procesy serwera i klienta.

  2. Pobierz gałąź z inteligentnymi kontraktami i upewnij się, że masz wszystkie niezbędne moduły.

    git checkout 03-smart-contracts
    cd client
    npm install
    cd ../server
    npm install
    
  3. Uruchom anvil w osobnym oknie wiersza poleceń.

  4. Wygeneruj klucz weryfikacyjny i weryfikator w Solidity, a następnie skopiuj kod weryfikatora do projektu 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. Przejdź do inteligentnych kontraktów i ustaw zmienne środowiskowe, aby używać blockchaina anvil.

    cd ../../smart-contracts
    export ETH_RPC_URL=http://localhost:8545
    ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
    
  6. Wdróż Verifier.sol i zapisz adres w zmiennej środowiskowej.

    VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'`
    echo $VERIFIER_ADDRESS
    
  7. Wdróż kontrakt 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
    

    Wartość 0x199..67b to hash Pedersena początkowego stanu Accounts. Jeśli zmodyfikujesz ten początkowy stan w server/index.mjs, możesz uruchomić transakcję, aby zobaczyć początkowy hash zgłoszony przez dowód z wiedzą zerową.

  8. Uruchom serwer.

    cd ../server
    npm run start
    
  9. Uruchom klienta w innym oknie wiersza poleceń.

    cd client
    npm run dev
    
  10. Uruchom kilka transakcji.

  11. Aby zweryfikować, czy stan zmienił się onchain, zrestartuj proces serwera. Zobacz, że ZkBank nie akceptuje już transakcji, ponieważ oryginalna wartość hasha w transakcjach różni się od wartości hasha przechowywanej onchain.

    To jest oczekiwany typ błędu.

server/index.mjs

Zmiany w tym pliku dotyczą głównie tworzenia właściwego dowodu i przesyłania go onchain.

import { exec } from 'child_process'
import util from 'util'

const execPromise = util.promisify(exec)

Musimy użyć pakietu Barretenberg (otwiera się w nowej karcie), aby utworzyć właściwy dowód do wysłania onchain. Możemy użyć tego pakietu albo uruchamiając interfejs wiersza poleceń (bb), albo używając biblioteki JavaScript, bb.js (otwiera się w nowej karcie). Biblioteka JavaScript jest znacznie wolniejsza niż natywne uruchamianie kodu, więc używamy tutaj exec (otwiera się w nowej karcie), aby skorzystać z wiersza poleceń.

Zauważ, że jeśli zdecydujesz się użyć bb.js, musisz użyć wersji kompatybilnej z używaną wersją Noir. W momencie pisania tego tekstu obecna wersja Noir (1.0.0-beta.11) używa bb.js w wersji 0.87.

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

Adres tutaj to ten, który otrzymujesz, gdy zaczynasz od czystego anvil i postępujesz zgodnie z powyższymi instrukcjami.

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

Ten klucz prywatny należy do jednego z domyślnych, zasilonych kont w anvil.

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

Wygeneruj dowód za pomocą pliku wykonywalnego bb.

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

Zapisz świadka do pliku.

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

Właściwe tworzenie dowodu. Ten krok tworzy również plik ze zmiennymi publicznymi, ale nie jest on nam potrzebny. Te zmienne otrzymaliśmy już z noir.execute.

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

Dowód to tablica JSON wartości Field, z których każda jest reprezentowana jako wartość szesnastkowa. Musimy jednak wysłać go w transakcji jako pojedynczą wartość bytes, którą Viem reprezentuje jako duży ciąg szesnastkowy. Tutaj zmieniamy format, łącząc wszystkie wartości, usuwając wszystkie 0x, a następnie dodając jedno na końcu.

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

    return proof
}

Posprzątaj i zwróć dowód.

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

    const publicFields = noirResult.returnValue.map(x=>'0x' + x.slice(2).padStart(64, "0"))

Pola publiczne muszą być tablicą 32-bajtowych wartości. Jednak ponieważ musieliśmy podzielić hash transakcji między dwie wartości Field, pojawia się on jako wartość 16-bajtowa. Tutaj dodajemy zera, aby Viem zrozumiał, że w rzeczywistości ma on 32 bajty.

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

Każdy adres używa danego nonce tylko raz, dzięki czemu możemy użyć kombinacji fromAddress i nonce jako unikalnego identyfikatora dla pliku świadka i katalogu wyjściowego.

Wyślij transakcję do łańcucha.

smart-contracts/src/ZkBank.sol

To jest kod onchain, który odbiera transakcję.

Kod onchain musi śledzić dwie zmienne: weryfikator (osobny kontrakt tworzony przez nargo) oraz obecny hash stanu.

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

Za każdym razem, gdy stan ulega zmianie, emitujemy zdarzenie TransactionProcessed.

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

Ta funkcja przetwarza transakcje. Otrzymuje dowód (jako bytes) i wejścia publiczne (jako tablicę bytes32) w formacie wymaganym przez weryfikator (aby zminimalizować przetwarzanie onchain, a co za tym idzie koszty gazu).

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

Dowód z wiedzą zerową musi potwierdzać, że transakcja zmienia nasz obecny hash na nowy.

        myVerifier.verify(_proof, _publicFields);

Wywołaj kontrakt weryfikatora, aby zweryfikować dowód z wiedzą zerową. Ten krok cofa transakcję, jeśli dowód z wiedzą zerową jest błędny.

Jeśli wszystko się zgadza, zaktualizuj hash stanu na nową wartość i wyemituj zdarzenie TransactionProcessed.

Nadużycia ze strony scentralizowanego komponentu

Bezpieczeństwo informacji składa się z trzech atrybutów:

  • Poufność, użytkownicy nie mogą czytać informacji, do których nie są upoważnieni.
  • Integralność, informacje nie mogą być zmieniane, z wyjątkiem przez upoważnionych użytkowników w autoryzowany sposób.
  • Dostępność, upoważnieni użytkownicy mogą korzystać z systemu.

W tym systemie integralność jest zapewniana poprzez dowody z wiedzą zerową. Dostępność jest znacznie trudniejsza do zagwarantowania, a poufność jest niemożliwa, ponieważ bank musi znać saldo każdego konta i wszystkie transakcje. Nie ma sposobu, aby zapobiec udostępnianiu informacji przez podmiot, który je posiada.

Możliwe byłoby stworzenie prawdziwie poufnego banku przy użyciu ukrytych adresów (stealth addresses) (otwiera się w nowej karcie), ale wykracza to poza zakres tego artykułu.

Fałszywe informacje

Jednym ze sposobów, w jaki serwer może naruszyć integralność, jest dostarczanie fałszywych informacji, gdy żądane są dane (otwiera się w nowej karcie).

Aby to rozwiązać, możemy napisać drugi program Noir, który otrzymuje konta jako wejście prywatne oraz adres, dla którego żądane są informacje, jako wejście publiczne. Wyjściem jest saldo i nonce tego adresu, a także hash kont.

Oczywiście tego dowodu nie można zweryfikować onchain, ponieważ nie chcemy publikować wartości nonce i sald onchain. Można go jednak zweryfikować za pomocą kodu klienta działającego w przeglądarce.

Wymuszone transakcje

Zwykłym mechanizmem zapewniania dostępności i zapobiegania cenzurze na L2 są wymuszone transakcje (forced transactions) (otwiera się w nowej karcie). Ale wymuszone transakcje nie łączą się z dowodami z wiedzą zerową. Serwer jest jedynym podmiotem, który może weryfikować transakcje.

Możemy zmodyfikować smart-contracts/src/ZkBank.sol, aby akceptował wymuszone transakcje i zapobiegał zmianie stanu przez serwer do czasu ich przetworzenia. Jednak to naraża nas na prosty atak typu odmowa usługi (denial-of-service). Co jeśli wymuszona transakcja jest nieważna i w związku z tym niemożliwa do przetworzenia?

Rozwiązaniem jest posiadanie dowodu z wiedzą zerową, że wymuszona transakcja jest nieważna. Daje to serwerowi trzy opcje:

  • Przetworzyć wymuszoną transakcję, dostarczając dowód z wiedzą zerową, że została przetworzona, oraz nowy hash stanu.
  • Odrzucić wymuszoną transakcję i dostarczyć kontraktowi dowód z wiedzą zerową, że transakcja jest nieważna (nieznany adres, zły nonce lub niewystarczające saldo).
  • Zignorować wymuszoną transakcję. Nie ma sposobu, aby zmusić serwer do faktycznego przetworzenia transakcji, ale oznacza to, że cały system jest niedostępny.

Kaucje dostępności

W rzeczywistej implementacji prawdopodobnie istniałby jakiś motyw zysku za utrzymanie działania serwera. Możemy wzmocnić tę zachętę, zmuszając serwer do wpłacenia kaucji dostępności (availability bond), którą każdy może spalić, jeśli wymuszona transakcja nie zostanie przetworzona w określonym czasie.

Zły kod Noir

Zazwyczaj, aby wzbudzić zaufanie ludzi do inteligentnego kontraktu, przesyłamy kod źródłowy do eksploratora bloków (otwiera się w nowej karcie). Jednak w przypadku dowodów z wiedzą zerową jest to niewystarczające.

Verifier.sol zawiera klucz weryfikacyjny, który jest funkcją programu Noir. Jednak ten klucz nie mówi nam, jaki to był program Noir. Aby faktycznie mieć zaufane rozwiązanie, należy przesłać program Noir (i wersję, która go utworzyła). W przeciwnym razie dowody z wiedzą zerową mogą odzwierciedlać inny program, taki z tylną furtką (back door).

Zanim eksploratory bloków zaczną pozwalać nam na przesyłanie i weryfikację programów Noir, powinieneś robić to sam (najlepiej do IPFS). Wtedy zaawansowani użytkownicy będą mogli pobrać kod źródłowy, skompilować go samodzielnie, utworzyć Verifier.sol i zweryfikować, czy jest identyczny z tym onchain.

Wniosek

Aplikacje typu Plasma wymagają scentralizowanego komponentu jako magazynu informacji. Otwiera to potencjalne luki w zabezpieczeniach, ale w zamian pozwala nam zachować prywatność w sposób niedostępny na samym blockchainie. Dzięki dowodom z wiedzą zerową możemy zapewnić integralność i potencjalnie uczynić ekonomicznie opłacalnym dla osoby zarządzającej scentralizowanym komponentem utrzymanie dostępności.

Zobacz tutaj więcej moich prac (otwiera się w nowej karcie).

Podziękowania

  • Josh Crites przeczytał szkic tego artykułu i pomógł mi z trudnym problemem w Noir.

Wszelkie pozostałe błędy są moją odpowiedzialnością.