Napisz specyficzną dla aplikacji plasmę, która zachowuje prywatność
Wprowadzenie
W przeciwieństwie do rollupów, plasmy używają sieci głównej Ethereum do zapewnienia integralności, ale nie dostępności. W tym artykule napiszemy aplikację, która zachowuje się jak plasma, z Ethereum gwarantującym integralność (brak nieautoryzowanych zmian), ale nie dostępność (scentralizowany komponent może ulec awarii i wyłączyć cały system).
Aplikacja, którą tu napiszemy, to bank chroniący prywatność. Różne adresy mają konta z saldami i mogą wysyłać pieniądze (ETH) na inne konta. Bank publikuje hasze stanu (konta i ich salda) oraz transakcje, ale faktyczne salda przechowuje poza łańcuchem, gdzie mogą pozostać prywatne.
Projekt
To nie jest system gotowy do produkcji, ale narzędzie dydaktyczne. W związku z tym został napisany 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. To sprawia, że system jest znacznie prostszy, ponieważ trudno jest obsługiwać struktury danych o zmiennym rozmiarze w dowodach o zerowej wiedzy. W przypadku systemu gotowego do produkcji możemy użyć korzenia Merkle'a jako hasza stanu i dostarczyć dowody Merkle'a dla wymaganych sald.
-
Przechowywanie w pamięci. W systemie produkcyjnym musimy zapisywać wszystkie salda kont na dysku, aby zachować je w przypadku ponownego uruchomienia. Tutaj jest w porządku, jeśli informacje zostaną po prostu utracone.
-
Tylko przelewy. System produkcyjny wymagałby sposobu na wpłacanie aktywów do banku i ich wypłacanie. Ale celem jest tu tylko zilustrowanie koncepcji, więc ten bank jest ograniczony do przelewów.
Dowody o zerowej wiedzy
Na poziomie podstawowym dowód o zerowej wiedzy pokazuje, że dowodzący zna pewne dane, Daneprywatne takie, że istnieje relacja Relacja między pewnymi danymi publicznymi, Danepubliczne, a Danymiprywatnymi. Weryfikator zna Relację i Danepubliczne.
Aby zachować prywatność, potrzebujemy, aby stany i transakcje były prywatne. Ale aby zapewnić integralność, potrzebujemy, aby hasz kryptograficzny (opens in a new tab) stanów był publiczny. Aby udowodnić osobom przesyłającym transakcje, że te transakcje rzeczywiście miały miejsce, musimy również publikować hasze transakcji.
W większości przypadków Daneprywatne są danymi wejściowymi do programu dowodu o zerowej wiedzy, a Danepubliczne są danymi wyjściowymi.
Te pola w Danychprywatnych:
- Stann, stary stan
- Stann+1, nowy stan
- Transakcja, transakcja, która zmienia stary stan na nowy. Ta transakcja musi zawierać następujące pola:
- Adres docelowy, który otrzymuje przelew
- Kwota przelewu
- Nonce, aby zapewnić, ż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żniający do wykonania transakcji. W naszym przypadku jedynym adresem upoważnionym do wykonania transakcji jest adres źródłowy. Ponieważ nasz system o zerowej wiedzy działa w taki sposób, oprócz podpisu Ethereum potrzebujemy również klucza publicznego konta.
Oto pola w Danychpublicznych:
- Hasz(Stann) hasz starego stanu
- Hasz(Stann+1) hasz nowego stanu
- Hasz(Transakcja) hasz transakcji, która zmienia stan ze Stanun na Stann+1.
Relacja sprawdza kilka warunków:
- Hasze publiczne są rzeczywiście poprawnymi haszami 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 funkcji haszujących kryptograficznie, udowodnienie tych warunków wystarczy, aby zapewnić integralność.
Struktury danych
Podstawową strukturą danych jest stan przechowywany przez serwer. Dla każdego konta serwer śledzi saldo konta i nonce (opens in a new tab), używany do zapobiegania atakom typu replay (opens in a new tab).
Komponenty
Ten system wymaga dwóch komponentów:
- Serwer, który odbiera transakcje, przetwarza je i publikuje hasze w łańcuchu wraz z dowodami o zerowej wiedzy.
- Inteligentny kontrakt, który przechowuje hasze i weryfikuje dowody o zerowej wiedzy, aby zapewnić, że przejścia stanów są prawidłowe.
Przepływ danych i sterowania
Są to sposoby, w jakie różne komponenty komunikują się w celu dokonania przelewu z jednego konta na drugie.
-
Przeglądarka internetowa przesyła podpisaną transakcję z prośbą o przelew z konta podpisującego na inne konto.
-
Serwer weryfikuje, czy transakcja jest prawidłowa:
- Podpisujący ma konto w banku z wystarczającym saldem.
- Odbiorca ma konto w banku.
-
Serwer oblicza nowy stan, odejmując przelaną kwotę od salda podpisującego i dodając ją do salda odbiorcy.
-
Serwer oblicza dowód o zerowej wiedzy, że zmiana stanu jest prawidłowa.
-
Serwer przesyła do Ethereum transakcję, która zawiera:
- Nowy hasz stanu
- Hasz transakcji (aby nadawca transakcji wiedział, że została przetworzona)
- Dowód o zerowej wiedzy, który udowadnia, że przejście do nowego stanu jest prawidłowe
-
Inteligentny kontrakt weryfikuje dowód o zerowej wiedzy.
-
Jeśli dowód o zerowej wiedzy jest poprawny, inteligentny kontrakt wykonuje następujące czynności:
- Zaktualizuj bieżący hasz stanu na nowy hasz stanu
- Wyemituj wpis w dzienniku z nowym haszem stanu i haszem transakcji
Narzędzia
Do kodu po stronie klienta użyjemy Vite (opens in a new tab), React (opens in a new tab), Viem (opens in a new tab) i Wagmi (opens in a new tab). Są to standardowe narzędzia branżowe; jeśli ich nie znasz, możesz skorzystać z tego samouczka.
Większość serwera jest napisana w JavaScript przy użyciu Node (opens in a new tab). Część dotycząca zerowej wiedzy jest napisana w Noir (opens in a new tab). Potrzebujemy wersji 1.0.0-beta.10, więc po zainstalowaniu Noir zgodnie z instrukcją (opens in a new tab), uruchom:
1noirup -v 1.0.0-beta.10Blockchain, którego używamy, to anvil, lokalny blockchain testowy, który jest częścią Foundry (opens in a new tab).
Implementacja
Ponieważ jest to złożony system, wdrożymy go etapami.
Etap 1 - Ręczna zerowa wiedza
W pierwszym etapie podpiszemy transakcję w przeglądarce, a następnie ręcznie podamy informacje do dowodu o zerowej wiedzy. Kod zerowej wiedzy oczekuje, że otrzyma te informacje w pliku server/noir/Prover.toml (udokumentowane tutaj (opens in a new tab)).
Aby zobaczyć to w działaniu:
-
Upewnij się, że masz zainstalowane Node (opens in a new tab) i Noir (opens in a new tab). Najlepiej zainstalować je w systemie UNIX, takim jak macOS, Linux lub WSL (opens in a new tab).
-
Pobierz kod etapu 1 i uruchom serwer WWW, aby obsługiwać kod klienta.
1git clone https://github.com/qbzzt/250911-zk-bank.git -b 01-manual-zk2cd 250911-zk-bank3cd client4npm install5npm run devPowodem, dla którego potrzebujesz tutaj serwera WWW, jest to, że aby zapobiec niektórym rodzajom oszustw, wiele portfeli (takich jak MetaMask) nie akceptuje plików serwowanych bezpośrednio z dysku
-
Otwórz przeglądarkę z portfelem.
-
W portfelu wprowadź nową frazę dostępu. Pamiętaj, że spowoduje to usunięcie istniejącej frazy dostępu, więc upewnij się, że masz kopię zapasową.
Fraza dostępu to
test test test test test test test test test test test junk, domyślna fraza testowa dla anvil. -
Przejdź do kodu po stronie klienta (opens in a new tab).
-
Połącz się z portfelem i wybierz konto docelowe oraz kwotę.
-
Kliknij Podpisz i podpisz transakcję.
-
Pod nagłówkiem Prover.toml znajdziesz tekst. Zastąp
server/noir/Prover.tomltym tekstem. -
Wykonaj dowód o zerowej wiedzy.
1cd ../server/noir2nargo executeDane wyjściowe powinny być podobne do
1ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute23[zkBank] Circuit witness successfully solved4[zkBank] Witness saved to target/zkBank.gz5[zkBank] Circuit output: (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85) -
Porównaj dwie ostatnie wartości z haszem widocznym w przeglądarce internetowej, aby sprawdzić, czy komunikat jest poprawnie haszowany.
server/noir/Prover.toml
Ten plik (opens in a new tab) pokazuje format informacji oczekiwany przez Noir.
1message="send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 500 finney (milliEth) 0 "Komunikat jest w formacie tekstowym, co ułatwia użytkownikowi jego zrozumienie (co jest konieczne przy podpisywaniu) i parsowanie przez kod Noir. Kwota jest podawana w finneyach, aby z jednej strony umożliwić przelewy ułamkowe, a z drugiej strony być łatwo czytelna. Ostatnia liczba to nonce (opens in a new tab).
Ciąg ma 100 znaków długości. Dowody o zerowej wiedzy nie radzą sobie dobrze z danymi o zmiennym rozmiarze, więc często konieczne jest uzupełnianie danych.
1pubKeyX=["0x83",...,"0x75"]2pubKeyY=["0x35",...,"0xa5"]3signature=["0xb1",...,"0x0d"]Te trzy parametry to tablice bajtów o stałym rozmiarze.
1[[accounts]]2address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"3balance=100_0004nonce=056[[accounts]]7address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"8balance=100_0009nonce=0Pokaż wszystkoW ten sposób określa się tablicę struktur. Dla każdego wpisu określamy adres, saldo (w milliETH, czyli finney (opens in a new tab)) oraz następną wartość nonce.
client/src/Transfer.tsx
Ten plik (opens in a new tab) implementuje przetwarzanie po stronie klienta i generuje plik server/noir/Prover.toml (ten, który zawiera parametry zerowej wiedzy).
Oto wyjaśnienie ciekawszych części.
1export default attrs => {Ta funkcja tworzy komponent React Transfer, który inne pliki mogą importować.
1 const accounts = [2 "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",3 "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",4 "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",5 "0x90F79bf6EB2c4f870365E785982E1f101E93b906",6 "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",7 ]Są to adresy kont, adresy utworzone przez test ... frazę dostępu test junk. Jeśli chcesz używać własnych adresów, po prostu zmodyfikuj tę definicję.
1 const account = useAccount()2 const wallet = createWalletClient({3 transport: custom(window.ethereum!)4 })Te hooki Wagmi (opens in a new tab) pozwalają nam uzyskać dostęp do biblioteki viem (opens in a new tab) i portfela.
1 const message = `send ${toAccount} ${ethAmount*1000} finney (milliEth) ${nonce}`.padEnd(100, " ")To jest komunikat, uzupełniony spacjami. Za każdym razem, gdy jedna ze zmiennych useState (opens in a new tab) się zmienia, komponent jest ponownie rysowany, a message jest aktualizowany.
1 const sign = async () => {Ta funkcja jest wywoływana, gdy użytkownik kliknie przycisk Podpisz. Komunikat jest aktualizowany automatycznie, ale podpis wymaga zatwierdzenia przez użytkownika w portfelu i nie chcemy o to prosić, jeśli nie jest to konieczne.
1 const signature = await wallet.signMessage({2 account: fromAccount,3 message,4 })Poproś portfel o podpisanie komunikatu (opens in a new tab).
1 const hash = hashMessage(message)Pobierz hasz komunikatu. Pomocne jest dostarczenie go użytkownikowi do debugowania (kodu Noir).
1 const pubKey = await recoverPublicKey({2 hash,3 signature4 })Pobierz klucz publiczny (opens in a new tab). Jest to wymagane dla funkcji Noir ecrecover (opens in a new tab).
1 setSignature(signature)2 setHash(hash)3 setPubKey(pubKey)Ustaw zmienne stanu. Zrobienie tego powoduje ponowne narysowanie komponentu (po wyjściu z funkcji sign) i pokazuje użytkownikowi zaktualizowane wartości.
1 let proverToml = `Tekst dla Prover.toml.
1message="${message}"23pubKeyX=${hexToArray(pubKey.slice(4,4+2*32))}4pubKeyY=${hexToArray(pubKey.slice(4+2*32))}Viem dostarcza nam klucz publiczny w postaci 65-bajtowego ciągu szesnastkowego. Pierwszy bajt to 0x04, znacznik wersji. Następnie 32 bajty dla x klucza publicznego, a potem 32 bajty dla y klucza publicznego.
Jednak Noir oczekuje, że otrzyma te informacje jako dwie tablice bajtów, jedną dla x i jedną dla y. Łatwiej jest to parsować tutaj po stronie klienta, niż jako część dowodu o zerowej wiedzy.
Należy zauważyć, że jest to ogólnie dobra praktyka w dowodach o zerowej wiedzy. Kod wewnątrz dowodu o zerowej wiedzy jest kosztowny, więc każde przetwarzanie, które można wykonać poza dowodem o zerowej wiedzy, powinno być wykonane poza nim.
1signature=${hexToArray(signature.slice(2,-2))}Podpis jest również dostarczany jako 65-bajtowy ciąg szesnastkowy. Jednak ostatni bajt jest potrzebny 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.
1${accounts.map(accountInProverToml).reduce((a,b) => a+b, "")}2`Podaj konta.
1 setProverToml(proverToml)2 }34 return (5 <>6 <h2>Przelew</h2>To jest format HTML (a dokładniej JSX (opens in a new tab)) komponentu.
server/noir/src/main.nr
Ten plik (opens in a new tab) to faktyczny kod o zerowej wiedzy.
1use std::hash::pedersen_hash;Hasz Pedersena (opens in a new tab) jest dostarczany ze standardową biblioteką Noir (opens in a new tab). Dowody o zerowej wiedzy często używają tej funkcji haszującej. Jest znacznie łatwiejszy do obliczenia wewnątrz obwodów arytmetycznych (opens in a new tab) w porównaniu ze standardowymi funkcjami haszującymi.
1use keccak256::keccak256;2use dep::ecrecover;Te dwie funkcje to biblioteki zewnętrzne, zdefiniowane w Nargo.toml (opens in a new tab). Są dokładnie tym, na co wskazują ich nazwy: funkcją, która oblicza hasz keccak256 (opens in a new tab) oraz funkcją, która weryfikuje podpisy Ethereum i odzyskuje adres Ethereum osoby podpisującej.
1global ACCOUNT_NUMBER : u32 = 5;Noir jest inspirowany językiem Rust (opens in a new tab). 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<liczba> to ta liczba bitów, bez znaku. Jedyne obsługiwane typy to u8, u16, u32, u64 i u128.
1global FLAT_ACCOUNT_FIELDS : u32 = 2;Ta zmienna jest używana do haszowania Pedersena kont, jak wyjaśniono poniżej.
1global MESSAGE_LENGTH : u32 = 100;Jak wyjaśniono powyżej, długość komunikatu jest stała. Jest ona określona tutaj.
1global ASCII_MESSAGE_LENGTH : [u8; 3] = [0x31, 0x30, 0x30];2global HASH_BUFFER_SIZE : u32 = 26+3+MESSAGE_LENGTH;Podpisy EIP-191 (opens in a new tab) wymagają bufora z 26-bajtowym prefiksem, po którym następuje długość komunikatu w ASCII, a na końcu sam komunikat.
1struct Account {2 balance: u128,3 address: Field,4 nonce: u32,5}Informacje, które przechowujemy o koncie. Pole (opens in a new tab) to liczba, zazwyczaj do 253 bitów, która może być używana bezpośrednio w obwodzie arytmetycznym (opens in a new tab), który implementuje dowód o zerowej wiedzy. Tutaj używamy Pola do przechowywania 160-bitowego adresu Ethereum.
1struct TransferTxn {2 from: Field,3 to: Field,4 amount: u128,5 nonce: u326}Informacje, które przechowujemy dla transakcji przelewu.
1fn flatten_account(account: Account) -> [Field; FLAT_ACCOUNT_FIELDS] {Definicja funkcji. Parametrem jest informacja o Koncie. Wynikiem jest tablica zmiennych Pole, której długość to FLAT_ACCOUNT_FIELDS
1 let flat = [2 account.address,3 ((account.balance << 32) + account.nonce.into()).into(),4 ];Pierwszą wartością w tablicy jest adres konta. Druga zawiera zarówno saldo, jak i nonce. Wywołania .into() zmieniają liczbę na typ danych, którym musi być. account.nonce to wartość u32, ale aby dodać ją do account.balance << 32, wartości u128, musi to być u128. To jest pierwsze .into(). Drugi konwertuje wynik u128 na Pole, aby zmieścił się w tablicy.
1 flat2}W Noir funkcje mogą zwracać wartość tylko na końcu (nie ma wcześniejszego zwrotu). Aby określić wartość zwracaną, należy ją obliczyć tuż przed nawiasem zamykającym funkcję.
1fn flatten_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] {Ta funkcja zamienia tablicę kont na tablicę Pól, która może być użyta jako dane wejściowe do hasza Petersena.
1 let mut flat: [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] = [0; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER];W ten sposób określa się zmienną mutowalną, czyli nie stałą. Zmienne w Noir muszą zawsze mieć wartość, więc inicjalizujemy tę zmienną samymi zerami.
1 for i in 0..ACCOUNT_NUMBER {To jest pętla for. Należy zauważyć, ż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ą kontroli przepływu. Podczas przetwarzania pętli for kompilator po prostu umieszcza kod wewnątrz niej wielokrotnie, raz dla każdej iteracji.
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 }67 flat8}910fn hash_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> Field {11 pedersen_hash(flatten_accounts(accounts))12}Pokaż wszystkoWreszcie doszliśmy do funkcji, która haszuje tablicę kont.
1fn find_account(accounts: [Account; ACCOUNT_NUMBER], address: Field) -> u32 {2 let mut account : u32 = ACCOUNT_NUMBER;34 for i in 0..ACCOUNT_NUMBER {5 if accounts[i].address == address {6 account = i;7 }8 }Ta funkcja znajduje konto o określonym adresie. Ta funkcja byłaby strasznie nieefektywna w standardowym kodzie, ponieważ iteruje po wszystkich kontach, nawet po znalezieniu adresu.
Jednak w dowodach o zerowej wiedzy nie ma kontroli przepływu. Jeśli kiedykolwiek będziemy musieli 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 = konta[i].adres == adres // jeden, jeśli są równe, zero w przeciwnym razie
kontonowe = wynikwarunku*i + (1-wynikwarunku)*kontostare
1 assert (account < ACCOUNT_NUMBER, f"{address} nie ma konta");23 account4}Funkcja assert (opens in a new tab) powoduje awarię dowodu o zerowej wiedzy, 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 (opens in a new tab).
1fn apply_transfer_txn(accounts: [Account; ACCOUNT_NUMBER], txn: TransferTxn) -> [Account; ACCOUNT_NUMBER] {Ta funkcja stosuje transakcję przelewu i zwraca nową tablicę kont.
1 let from = find_account(accounts, txn.from);2 let to = find_account(accounts, txn.to);34 let (txnFrom, txnAmount, txnNonce, accountNonce) =5 (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ę.
1 assert (accounts[from].balance >= txn.amount,2 f"{txnFrom} nie ma {txnAmount} finney");34 assert (accounts[from].nonce == txn.nonce,5 f"Transakcja ma nonce {txnNonce}, ale oczekuje się, że konto użyje {accountNonce}");Są to dwa warunki, które mogą unieważnić transakcję.
1 let mut newAccounts = accounts;23 newAccounts[from].balance -= txn.amount;4 newAccounts[from].nonce += 1;5 newAccounts[to].balance += txn.amount;67 newAccounts8}Utwórz nową tablicę kont, a następnie ją zwróć.
1fn readAddress(messageBytes: [u8; MESSAGE_LENGTH]) -> FieldTa funkcja odczytuje adres z komunikatu.
1{2 let mut result : Field = 0;34 for i in 7..47 {Adres ma zawsze 20 bajtów (czyli 40 cyfr szesnastkowych) długości i zaczyna się od znaku nr 7.
1 result *= 0x10;2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-93 result += (messageBytes[i]-48).into();4 }5 if messageBytes[i] >= 65 & messageBytes[i] <= 70 { // A-F6 result += (messageBytes[i]-65+10).into()7 }8 if messageBytes[i] >= 97 & messageBytes[i] <= 102 { // a-f9 result += (messageBytes[i]-97+10).into()10 } 11 } 1213 result14}1516fn readAmountAndNonce(messageBytes: [u8; MESSAGE_LENGTH]) -> (u128, u32)Pokaż wszystkoOdczytaj kwotę i nonce z komunikatu.
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;W komunikacie pierwsza liczba po adresie to kwota finney (czyli tysięczna ETH) do przelania. Druga liczba to nonce. Każdy tekst między nimi jest ignorowany.
1 for i in 48..MESSAGE_LENGTH {2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-93 let digit = (messageBytes[i]-48);45 if stillReadingAmount {6 amount = amount*10 + digit.into();7 }89 if lookingForNonce { // Właśnie to znaleźliśmy10 stillReadingNonce = true;11 lookingForNonce = false;12 }1314 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 }2728 (amount, nonce)29}Pokaż wszystkoZwracanie krotki (opens in a new tab) to sposób Noir na zwracanie wielu wartości z funkcji.
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();56 txn.to = readAddress(messageBytes);7 let (amount, nonce) = readAmountAndNonce(messageBytes);8 txn.amount = amount;9 txn.nonce = nonce;1011 txn12}Pokaż wszystkoTa funkcja konwertuje komunikat na bajty, a następnie konwertuje kwoty na TransferTxn.
1// Odpowiednik hashMessage Viem2// https://viem.sh/docs/utilities/hashMessage#hashmessage3fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {Mogliśmy użyć hasza Pedersena dla kont, ponieważ są one haszowane tylko wewnątrz dowodu o zerowej wiedzy. Jednak w tym kodzie musimy sprawdzić podpis komunikatu, który jest generowany przez przeglądarkę. W tym celu musimy postępować zgodnie z formatem podpisywania Ethereum w EIP 191 (opens in a new tab). Oznacza to, że musimy utworzyć połączony bufor ze standardowym prefiksem, długością komunikatu w ASCII i samym komunikatem, a następnie użyć standardowego dla Ethereum keccak256 do jego haszowania.
1 // prefiks ASCII2 let prefix_bytes = [3 0x19, // \x194 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 ];Pokaż wszystkoAby uniknąć przypadków, w których aplikacja prosi użytkownika o podpisanie komunikatu, który może być użyty jako transakcja lub w innym celu, EIP 191 określa, że wszystkie podpisane komunikaty zaczynają się od znaku 0x19 (nie jest to prawidłowy znak ASCII), po którym następuje Ethereum Signed Message: i nowa linia.
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 }56 let messageBytes : [u8; MESSAGE_LENGTH] = message.as_bytes();78 if MESSAGE_LENGTH <= 9 {9 for i in 0..1 {10 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];11 }1213 for i in 0..MESSAGE_LENGTH {14 buffer[i+26+1] = messageBytes[i];15 }16 }1718 if MESSAGE_LENGTH >= 10 & MESSAGE_LENGTH <= 99 {19 for i in 0..2 {20 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];21 }2223 for i in 0..MESSAGE_LENGTH {24 buffer[i+26+2] = messageBytes[i];25 }26 }2728 if MESSAGE_LENGTH >= 100 {29 for i in 0..3 {30 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];31 }3233 for i in 0..MESSAGE_LENGTH {34 buffer[i+26+3] = messageBytes[i];35 }36 }3738 assert(MESSAGE_LENGTH < 1000, "Komunikaty, których długość przekracza trzy cyfry, nie są obsługiwane");Pokaż wszystkoObsługuj komunikaty o długości do 999 i zakończ niepowodzeniem, jeśli jest większa. Dodałem ten kod, mimo że długość komunikatu jest stała, ponieważ ułatwia to jego zmianę. W systemie produkcyjnym prawdopodobnie po prostu założyłbyś, że MESSAGE_LENGTH nie zmienia się ze względu na lepszą wydajność.
1 keccak256::keccak256(buffer, HASH_BUFFER_SIZE)2}Użyj standardowej funkcji Ethereum keccak256.
1fn signatureToAddressAndHash(2 message: str<MESSAGE_LENGTH>, 3 pubKeyX: [u8; 32],4 pubKeyY: [u8; 32],5 signature: [u8; 64]6 ) -> (Field, Field, Field) // adres, pierwsze 16 bajtów hasza, ostatnie 16 bajtów hasza 7{Ta funkcja weryfikuje podpis, co wymaga hasza komunikatu. Następnie dostarcza nam adres, który go podpisał, oraz hasz komunikatu. Hasz komunikatu jest dostarczany w dwóch wartościach Pole, ponieważ są one łatwiejsze w użyciu w pozostałej części programu niż tablica bajtów.
Musimy użyć dwóch wartości Pole, ponieważ obliczenia na polach są wykonywane modulo (opens in a new tab) dużej liczby, ale ta liczba jest zwykle mniejsza niż 256 bitów (w przeciwnym razie trudno byłoby wykonać te obliczenia w EVM).
1 let hash = hashMessage(message);23 let mut (hash1, hash2) = (0,0);45 for i in 0..16 {6 hash1 = hash1*256 + hash[31-i].into();7 hash2 = hash2*256 + hash[15-i].into();8 }Określ hash1 i hash2 jako zmienne mutowalne i zapisz w nich hasz bajt po bajcie.
1 (2 ecrecover::ecrecover(pubKeyX, pubKeyY, signature, hash), Jest to podobne do ecrecover w Solidity (opens in a new tab), z dwiema ważnymi różnicami:
- Jeśli podpis nie jest prawidłowy, wywołanie kończy się niepowodzeniem
assert, a program jest przerywany. - Chociaż klucz publiczny można odzyskać z podpisu i hasza, jest to przetwarzanie, które można wykonać zewnętrznie, a zatem nie warto go robić wewnątrz dowodu o zerowej wiedzy. Jeśli ktoś spróbuje nas tu oszukać, weryfikacja podpisu zakończy się niepowodzeniem.
1 hash1,2 hash23 )4}56fn 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, // Hasz starej tablicy kont14 Field, // Hasz nowej tablicy kont15 Field, // Pierwsze 16 bajtów hasza komunikatu16 Field, // Ostatnie 16 bajtów hasza komunikatu17 )Pokaż wszystkoWreszcie dochodzimy do funkcji main. Musimy udowodnić, że mamy transakcję, która prawidłowo zmienia hasz kont ze starej wartości na nową. Musimy również udowodnić, że ma ona ten konkretny hasz transakcji, aby osoba, która ją wysłała, wiedziała, że jej transakcja została przetworzona.
1{2 let mut txn = readTransferTxn(message);Potrzebujemy, aby txn było mutowalne, ponieważ nie odczytujemy adresu nadawcy z komunikatu, ale z podpisu.
1 let (fromAddress, txnHash1, txnHash2) = signatureToAddressAndHash(2 message,3 pubKeyX,4 pubKeyY,5 signature);67 txn.from = fromAddress;89 let newAccounts = apply_transfer_txn(accounts, txn);1011 (12 hash_accounts(accounts),13 hash_accounts(newAccounts),14 txnHash1,15 txnHash216 )17}Pokaż wszystkoEtap 2 - Dodawanie serwera
W drugim etapie dodajemy serwer, który odbiera i implementuje transakcje przelewów z przeglądarki.
Aby zobaczyć to w działaniu:
-
Zatrzymaj Vite, jeśli jest uruchomiony.
-
Pobierz gałąź, która zawiera serwer i upewnij się, że masz wszystkie niezbędne moduły.
1git checkout 02-add-server2cd client3npm install4cd ../server5npm installNie ma potrzeby kompilowania kodu Noir, jest on taki sam jak kod, którego użyłeś w etapie 1.
-
Uruchom serwer.
1npm run start -
W osobnym oknie wiersza poleceń uruchom Vite, aby obsługiwać kod przeglądarki.
1cd client2npm run dev -
Przejdź do kodu klienta pod adresem http://localhost:5173 (opens in a new tab)
-
Zanim będziesz mógł wystawić transakcję, musisz znać nonce, a także kwotę, którą możesz wysłać. Aby uzyskać te informacje, kliknij Aktualizuj dane konta i podpisz komunikat.
Mamy tu dylemat. Z jednej strony nie chcemy podpisywać komunikatu, który można ponownie wykorzystać (atak typu replay (opens in a new tab)), dlatego w ogóle chcemy nonce. Jednak nie mamy jeszcze nonce. Rozwiązaniem jest wybranie nonce, które można użyć tylko raz i które już mamy po obu stronach, na przykład bieżący czas.
Problem z tym rozwiązaniem polega na tym, że czas może nie być idealnie zsynchronizowany. Zamiast tego podpisujemy wartość, która zmienia się co minutę. Oznacza to, że nasze okno podatności na ataki typu replay wynosi co najwyżej jedną minutę. Biorąc pod uwagę, że w środowisku produkcyjnym 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.
-
Gdy przeglądarka otrzyma z powrotem saldo i nonce, wyświetli formularz przelewu. Wybierz adres docelowy i kwotę, a następnie kliknij Przelej. Podpisz to żądanie.
-
Aby zobaczyć przelew, kliknij Aktualizuj dane konta lub spójrz w okno, w którym uruchamiasz serwer. Serwer rejestruje stan za każdym razem, gdy się zmienia.
1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start23> server@1.0.0 start4> node --experimental-json-modules index.mjs56Nasłuchiwanie na porcie 30007Transakcja send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 przetworzona8Nowy stan:90xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 ma 64000 (1)100x70997970C51812dc3A010C7d01b50e0d17dc79C8 ma 100000 (0)110x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC ma 100000 (0)120x90F79bf6EB2c4f870365E785982E1f101E93b906 ma 136000 (0)130x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 ma 100000 (0)14Transakcja send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 7200 finney (milliEth) 1 przetworzona15Nowy stan:160xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 ma 56800 (2)170x70997970C51812dc3A010C7d01b50e0d17dc79C8 ma 107200 (0)180x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC ma 100000 (0)190x90F79bf6EB2c4f870365E785982E1f101E93b906 ma 136000 (0)200x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 ma 100000 (0)21Transakcja send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 3000 finney (milliEth) 2 przetworzona22Nowy stan:230xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 ma 53800 (3)240x70997970C51812dc3A010C7d01b50e0d17dc79C8 ma 107200 (0)250x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC ma 100000 (0)260x90F79bf6EB2c4f870365E785982E1f101E93b906 ma 139000 (0)270x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 ma 100000 (0)Pokaż wszystko
server/index.mjs
Ten plik (opens in a new tab) zawiera proces serwera i współdziała z kodem Noir w main.nr (opens in a new tab). Oto wyjaśnienie ciekawszych części.
1import { Noir } from '@noir-lang/noir_js'Biblioteka noir.js (opens in a new tab) stanowi interfejs między kodem JavaScript a kodem Noir.
1const circuit = JSON.parse(await fs.readFile("./noir/target/zkBank.json"))2const noir = new Noir(circuit)Załaduj obwód arytmetyczny — skompilowany program Noir, który stworzyliśmy w poprzednim etapie — i przygotuj go do wykonania.
1// Informacje o koncie udostępniamy tylko w odpowiedzi na podpisane żądanie2const accountInformation = async signature => {3 const fromAddress = await recoverAddress({4 hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),5 signature6 })Aby podać informacje o koncie, potrzebujemy tylko podpisu. Powodem jest to, że wiemy już, jaki będzie komunikat, a zatem znamy jego hasz.
1const processMessage = async (message, signature) => {Przetwórz komunikat i wykonaj zakodowaną w nim transakcję.
1 // Pobierz klucz publiczny2 const pubKey = await recoverPublicKey({3 hash,4 signature5 })Teraz, gdy uruchamiamy JavaScript na serwerze, możemy pobrać klucz publiczny tam, a nie na kliencie.
1 let noirResult2 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: Accounts9 })Pokaż wszystkonoir.execute uruchamia program Noir. Parametry są równoważne z tymi podanymi w Prover.toml (opens in a new tab). Zauważ, że długie wartości są podawane jako tablica ciągów szesnastkowych (["0x60", "0xA7"]), a nie jako pojedyncza wartość szesnastkowa (0x60A7), tak jak to robi Viem.
1 } catch (err) {2 console.log(`Noir error: ${err}`)3 throw Error("Nieprawidłowa transakcja, nie przetworzono")4 }Jeśli wystąpi błąd, przechwyć go, a następnie przekaż uproszczoną wersję do klienta.
1 Accounts[fromAccountNumber].nonce++2 Accounts[fromAccountNumber].balance -= amount3 Accounts[toAccountNumber].balance += amountZastosuj transakcję. Zrobiliśmy to już w kodzie Noir, ale łatwiej jest to zrobić ponownie tutaj, niż wyodrębniać stamtąd wynik.
1let Accounts = [2 {3 address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",4 balance: 5000,5 nonce: 0,6 },Początkowa struktura Konta.
Etap 3 - Inteligentne kontrakty Ethereum
-
Zatrzymaj procesy serwera i klienta.
-
Pobierz gałąź z inteligentnymi kontraktami i upewnij się, że masz wszystkie niezbędne moduły.
1git checkout 03-smart-contracts2cd client3npm install4cd ../server5npm install -
Uruchom
anvilw osobnym oknie wiersza poleceń. -
Wygeneruj klucz weryfikacyjny i weryfikator solidity, a następnie skopiuj kod weryfikatora do projektu Solidity.
1cd noir2bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak3bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol4cp target/Verifier.sol ../../smart-contracts/src -
Przejdź do inteligentnych kontraktów i ustaw zmienne środowiskowe, aby używać blockchaina
anvil.1cd ../../smart-contracts2export ETH_RPC_URL=http://localhost:85453ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -
Wdróż
Verifier.soli przechowaj adres w zmiennej środowiskowej.1VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'`2echo $VERIFIER_ADDRESS -
Wdróż kontrakt
ZkBank.1ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'`2echo $ZKBANK_ADDRESSWartość
0x199..67bto hasz Pedersena początkowego stanuKont. Jeśli zmodyfikujesz ten stan początkowy w plikuserver/index.mjs, możesz uruchomić transakcję, aby zobaczyć początkowy hasz zgłoszony przez dowód o zerowej wiedzy. -
Uruchom serwer.
1cd ../server2npm run start -
Uruchom klienta w innym oknie wiersza poleceń.
1cd client2npm run dev -
Uruchom kilka transakcji.
-
Aby zweryfikować, czy stan zmienił się w łańcuchu, uruchom ponownie proces serwera. Zobaczysz, że
ZkBanknie akceptuje już transakcji, ponieważ oryginalna wartość hasza w transakcjach różni się od wartości hasza przechowywanej w łańcuchu.Jest to oczekiwany typ błędu.
1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start23> server@1.0.0 start4> node --experimental-json-modules index.mjs56Nasłuchiwanie na porcie 30007Błąd weryfikacji: ContractFunctionExecutionError: Funkcja kontraktu "processTransaction" została cofnięta z następującego powodu:8Zły stary hasz stanu910Wywołanie kontraktu:11 adres: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F051212 funkcja: processTransaction(bytes _proof, bytes32[] _publicInputs)13 argumenty: (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000Pokaż wszystko
server/index.mjs
Zmiany w tym pliku dotyczą głównie tworzenia faktycznego dowodu i przesyłania go do łańcucha.
1import { exec } from 'child_process'2import util from 'util'34const execPromise = util.promisify(exec)Musimy użyć pakietu Barretenberg (opens in a new tab), aby utworzyć faktyczny dowód do wysłania w łańcuchu. Możemy użyć tego pakietu, uruchamiając interfejs wiersza poleceń (bb) lub używając biblioteki JavaScript, bb.js (opens in a new tab). Biblioteka JavaScript jest znacznie wolniejsza niż natywne uruchamianie kodu, więc używamy tutaj exec (opens in a new tab), aby użyć wiersza poleceń.
Zauważ, że jeśli zdecydujesz się na użycie bb.js, musisz użyć wersji kompatybilnej z wersją Noir, której używasz. W chwili pisania tego tekstu obecna wersja Noir (1.0.0-beta.11) używa bb.js w wersji 0.87.
1const zkBankAddress = process.env.ZKBANK_ADDRESS || "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"Adres tutaj jest tym, który otrzymujesz, gdy zaczynasz z czystym anvil i postępujesz zgodnie z powyższymi wskazówkami.
1const walletClient = createWalletClient({ 2 chain: anvil, 3 transport: http(), 4 account: privateKeyToAccount("0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6")5})Ten klucz prywatny jest jednym z domyślnych, wstępnie zasilonych kont w anvil.
1const generateProof = async (witness, fileID) => {Wygeneruj dowód za pomocą pliku wykonywalnego bb.
1 const fname = `witness-${fileID}.gz` 2 await fs.writeFile(fname, witness)Zapisz świadka do pliku.
1 await execPromise(`bb prove -b ./noir/target/zkBank.json -w ${fname} -o ${fileID} --oracle_hash keccak --output_format fields`)Faktycznie utwórz dowód. Ten krok tworzy również plik ze zmiennymi publicznymi, ale nie potrzebujemy go. Otrzymaliśmy już te zmienne z noir.execute.
1 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 Pole, z których każda jest reprezentowana jako wartość szesnastkowa. Musimy jednak wysłać go w transakcji jako pojedynczą wartość bajtów, którą Viem reprezentuje za pomocą dużego ciągu szesnastkowego. Tutaj zmieniamy format, łącząc wszystkie wartości, usuwając wszystkie 0x, a następnie dodając jedno na końcu.
1 await execPromise(`rm -r ${fname} ${fileID}`)23 return proof4}Posprzątaj i zwróć dowód.
1const processMessage = async (message, signature) => {2 .3 .4 .56 const publicFields = noirResult.returnValue.map(x=>'0x' + x.slice(2).padStart(64, "0"))Pola publiczne muszą być tablicą 32-bajtowych wartości. Jednakże, ponieważ musieliśmy podzielić hasz transakcji na dwie wartości Pole, pojawia się on jako wartość 16-bajtowa. Tutaj dodajemy zera, aby Viem zrozumiał, że jest to w rzeczywistości 32 bajty.
1 const proof = await generateProof(noirResult.witness, `${fromAddress}-${nonce}`)Każdy adres używa każdego nonce tylko raz, więc możemy użyć kombinacji fromAddress i nonce jako unikalnego identyfikatora dla pliku świadka i katalogu wyjściowego.
1 try {2 await zkBank.write.processTransaction([3 proof, publicFields])4 } catch (err) {5 console.log(`Verification error: ${err}`)6 throw Error("Nie można zweryfikować transakcji w łańcuchu")7 }8 .9 .10 .11}Pokaż wszystkoWyślij transakcję do łańcucha.
smart-contracts/src/ZkBank.sol
To jest kod w łańcuchu, który odbiera transakcję.
1// SPDX-License-Identifier: MIT23pragma solidity >=0.8.21;45import {HonkVerifier} from "./Verifier.sol";67contract ZkBank {8 HonkVerifier immutable myVerifier;9 bytes32 currentStateHash;1011 constructor(address _verifierAddress, bytes32 _initialStateHash) {12 currentStateHash = _initialStateHash;13 myVerifier = HonkVerifier(_verifierAddress);14 }Pokaż wszystkoKod w łańcuchu musi śledzić dwie zmienne: weryfikator (osobny kontrakt tworzony przez nargo) i bieżący hasz stanu.
1 event TransactionProcessed(2 bytes32 indexed transactionHash,3 bytes32 oldStateHash,4 bytes32 newStateHash5 );Za każdym razem, gdy stan się zmienia, emitujemy zdarzenie TransactionProcessed.
1 function processTransaction(2 bytes calldata _proof,3 bytes32[] calldata _publicFields4 ) public {Ta funkcja przetwarza transakcje. Otrzymuje dowód (jako bajty) i publiczne dane wejściowe (jako tablicę bytes32), w formacie wymaganym przez weryfikator (w celu zminimalizowania przetwarzania w łańcuchu, a tym samym kosztów gazu).
1 require(_publicInputs[0] == currentStateHash,2 "Zły stary hasz stanu");Dowód o zerowej wiedzy musi polegać na tym, że transakcja zmienia nasz obecny hasz na nowy.
1 myVerifier.verify(_proof, _publicFields);Wywołaj kontrakt weryfikatora, aby zweryfikować dowód o zerowej wiedzy. Ten krok cofa transakcję, jeśli dowód o zerowej wiedzy jest nieprawidłowy.
1 currentStateHash = _publicFields[1];23 emit TransactionProcessed(4 _publicFields[2]<<128 | _publicFields[3],5 _publicFields[0],6 _publicFields[1]7 );8 }9}Pokaż wszystkoJeśli wszystko się zgadza, zaktualizuj hasz stanu na nową wartość i wyemituj zdarzenie TransactionProcessed.
Nadużycia przez scentralizowany komponent
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 upoważnionych użytkowników w autoryzowany sposób.
- Dostępność, autoryzowani użytkownicy mogą korzystać z systemu.
W tym systemie integralność jest zapewniana poprzez dowody o zerowej wiedzy. 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 uniemożliwić podmiotowi, który posiada informacje, udostępnianie tych informacji.
Możliwe byłoby stworzenie prawdziwie poufnego banku przy użyciu adresów stealth (opens in a new tab), ale to wykracza 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 (opens in a new tab).
Aby to rozwiązać, możemy napisać drugi program Noir, który otrzymuje konta jako prywatne dane wejściowe i adres, dla którego żądane są informacje, jako publiczne dane wejściowe. Danymi wyjściowymi są saldo i nonce tego adresu oraz hasz kont.
Oczywiście tego dowodu nie można zweryfikować w łańcuchu, ponieważ nie chcemy publikować nonce i sald w łańcuchu. Można go jednak zweryfikować za pomocą kodu klienta działającego w przeglądarce.
Wymuszone transakcje
Zwykłym mechanizmem zapewniającym dostępność i zapobiegającym cenzurze na L2 są wymuszone transakcje (opens in a new tab). Ale wymuszone transakcje nie łączą się z dowodami o zerowej wiedzy. Serwer jest jedynym podmiotem, który może weryfikować transakcje.
Możemy zmodyfikować smart-contracts/src/ZkBank.sol, aby akceptować wymuszone transakcje i uniemożliwić serwerowi zmianę stanu do czasu ich przetworzenia. Jednak to naraża nas na prosty atak typu „odmowa usługi”. Co jeśli wymuszona transakcja jest nieprawidłowa i dlatego niemożliwa do przetworzenia?
Rozwiązaniem jest posiadanie dowodu o zerowej wiedzy, że wymuszona transakcja jest nieprawidłowa. Daje to serwerowi trzy opcje:
- Przetwórz wymuszoną transakcję, dostarczając dowód o zerowej wiedzy, że została przetworzona i nowy hasz stanu.
- Odrzuć wymuszoną transakcję i dostarcz do kontraktu dowód o zerowej wiedzy, że transakcja jest nieprawidłowa (nieznany adres, zły nonce lub niewystarczające saldo).
- Zignoruj wymuszoną transakcję. Nie ma sposobu, aby zmusić serwer do faktycznego przetworzenia transakcji, ale oznacza to, że cały system jest niedostępny.
Obligacje dostępności
W rzeczywistej implementacji prawdopodobnie istniałby jakiś motyw zysku, aby serwer działał. Możemy wzmocnić tę zachętę, zmuszając serwer do opublikowania obligacji dostępności, którą każdy może spalić, jeśli wymuszona transakcja nie zostanie przetworzona w określonym czasie.
Zły kod Noir
Zwykle, aby ludzie zaufali inteligentnemu kontraktowi, przesyłamy kod źródłowy do eksploratora bloków (opens in a new tab). Jednak w przypadku dowodów o zerowej wiedzy jest to niewystarczające.
Verifier.sol zawiera klucz weryfikacyjny, który jest funkcją programu Noir. Jednak ten klucz nie mówi nam, jaki 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 o zerowej wiedzy mogą odzwierciedlać inny program, z tylnymi drzwiami.
Dopóki eksploratory bloków nie zaczną pozwalać nam na przesyłanie i weryfikowanie programów Noir, powinieneś robić to samodzielnie (najlepiej do IPFS). Wtedy zaawansowani użytkownicy będą mogli pobrać kod źródłowy, samodzielnie go skompilować, utworzyć Verifier.sol i zweryfikować, czy jest on identyczny z tym w łańcuchu.
Wnioski
Aplikacje typu plasma wymagają scentralizowanego komponentu do przechowywania informacji. To otwiera potencjalne luki w zabezpieczeniach, ale w zamian pozwala nam zachować prywatność w sposób niedostępny w samym blockchainie. Dzięki dowodom o zerowej wiedzy możemy zapewnić integralność i ewentualnie sprawić, że utrzymanie dostępności będzie ekonomicznie korzystne dla każdego, kto prowadzi scentralizowany komponent.
Zobacz więcej mojej pracy tutaj (opens in a new tab).
Podziękowania
- Josh Crites przeczytał szkic tego artykułu i pomógł mi w kłopotliwej kwestii związanej z Noir.
Wszelkie pozostałe błędy są moją odpowiedzialnością.
Strona ostatnio zaktualizowana: 28 października 2025