Přeskočit na hlavní obsah

Napište plazmu specifickou pro aplikaci, která zachovává soukromí

nulová znalost
server
offchain
soukromí
Další
Ori Pomerantz
15. října 2025
29 minuta čtení

Úvod

Na rozdíl od rollupů používají plazmy hlavní síť Ethereum k zajištění integrity, nikoli však dostupnosti. V tomto článku napíšeme aplikaci, která se chová jako plazma, přičemž Ethereum zaručuje integritu (žádné neoprávněné změny), ale nikoli dostupnost (centralizovaná součást může selhat a vyřadit celý systém).

Aplikace, kterou zde píšeme, je banka zachovávající soukromí. Různé adresy mají účty se zůstatky a mohou posílat peníze (ETH) na jiné účty. Banka zveřejňuje hashe stavu (účty a jejich zůstatky) a transakce, ale skutečné zůstatky drží mimo blockchain, kde mohou zůstat soukromé.

Návrh

Nejedná se o systém připravený pro produkční nasazení, ale o výukový nástroj. Jako takový je napsán s několika zjednodušujícími předpoklady.

  • Pevně daný fond účtů. Existuje určitý počet účtů a každý účet patří na předem určenou adresu. Díky tomu je systém mnohem jednodušší, protože v důkazech s nulovou znalostí je obtížné pracovat s datovými strukturami s proměnlivou velikostí. Pro systém připravený pro produkční nasazení můžeme použít Merkle kořen jako hash stavu a poskytnout Merkle důkazy pro požadované zůstatky.

  • Ukládání do paměti. V produkčním systému je třeba zapisovat všechny zůstatky na účtech na disk, aby se zachovaly pro případ restartu. Zde je v pořádku, pokud se informace jednoduše ztratí.

  • Pouze převody. Produkční systém by vyžadoval způsob, jak vkládat prostředky do banky a jak je vybírat. Cílem je zde však pouze ilustrovat koncept, takže tato banka je omezena na převody.

Důkazy s nulovou znalostí

Na základní úrovni důkaz s nulovou znalostí ukazuje, že dokazující zná nějaká data, Datasoukromá, taková, že existuje vztah Vztah mezi nějakými veřejnými daty, Dataveřejná, a Datasoukromá. Ověřovatel zná Vztah a Dataveřejná.

Abychom zachovali soukromí, je třeba, aby stavy a transakce byly soukromé. Abychom však zajistili integritu, potřebujeme, aby kryptografický hash (opens in a new tab) stavů byl veřejný. Abychom lidem, kteří odesílají transakce, dokázali, že se tyto transakce skutečně uskutečnily, musíme také zveřejňovat hashe transakcí.

Ve většině případů je Datasoukromá vstupem do programu důkazu s nulovou znalostí a Dataveřejná je výstupem.

Tato pole v Datasoukromá:

  • Stavn, starý stav
  • Stavn+1, nový stav
  • Transakce, transakce, která mění starý stav na nový. Tato transakce musí obsahovat tato pole:
    • Cílová adresa, která přijímá převod
    • Částka, která se převádí
    • Nonce, aby se zajistilo, že každá transakce může být zpracována pouze jednou. Zdrojová adresa nemusí být v transakci, protože ji lze obnovit z podpisu.
  • Podpis, podpis, který je oprávněn provést transakci. V našem případě je jedinou adresou oprávněnou k provedení transakce zdrojová adresa. Protože náš systém s nulovou znalostí funguje tak, jak funguje, potřebujeme kromě podpisu Ethereum také veřejný klíč účtu.

Toto jsou pole v Dataveřejná:

  • Hash(Stavn) hash starého stavu
  • Hash(Stavn+1) hash nového stavu
  • Hash(Transakce) hash transakce, která mění stav ze Stavun na Stavn+1.

Vztah kontroluje několik podmínek:

  • Veřejné hashe jsou skutečně správnými hashi pro soukromá pole.
  • Transakce, když se aplikuje na starý stav, má za následek nový stav.
  • Podpis pochází ze zdrojové adresy transakce.

Vzhledem k vlastnostem kryptografických hashovacích funkcí stačí prokázat tyto podmínky k zajištění integrity.

Datové struktury

Primární datovou strukturou je stav, který uchovává server. Pro každý účet server sleduje zůstatek na účtu a nonce (opens in a new tab), které se používá k zabránění opakovacím útokům (opens in a new tab).

Komponenty

Tento systém vyžaduje dvě součásti:

  • Server, který přijímá transakce, zpracovává je a zveřejňuje hashe na řetězci spolu s důkazy s nulovou znalostí.
  • Chytrý kontrakt, který ukládá hashe a ověřuje důkazy s nulovou znalostí, aby se zajistilo, že přechody stavů jsou legitimní.

Datový a řídicí tok

Toto jsou způsoby, jakými jednotlivé součásti komunikují při převodu z jednoho účtu na druhý.

  1. Webový prohlížeč odešle podepsanou transakci s žádostí o převod z účtu podepisujícího na jiný účet.

  2. Server ověří, že transakce je platná:

    • Podepisující má v bance účet s dostatečným zůstatkem.
    • Příjemce má v bance účet.
  3. Server vypočítá nový stav odečtením převedené částky od zůstatku podepisujícího a jejím přičtením k zůstatku příjemce.

  4. Server vypočítá důkaz s nulovou znalostí, že změna stavu je platná.

  5. Server odešle na Ethereum transakci, která obsahuje:

    • Nový hash stavu
    • Hash transakce (aby odesílatel transakce věděl, že byla zpracována)
    • Důkaz s nulovou znalostí, který dokazuje, že přechod do nového stavu je platný
  6. Chytrý kontrakt ověří důkaz s nulovou znalostí.

  7. Pokud se důkaz s nulovou znalostí ověří, chytrý kontrakt provede tyto akce:

    • Aktualizace současného hashe stavu na nový hash stavu
    • Vydá záznam do protokolu s novým hashem stavu a hashem transakce

Nástroje

Pro kód na straně klienta použijeme Vite (opens in a new tab), React (opens in a new tab), Viem (opens in a new tab) a Wagmi (opens in a new tab). Jedná se o standardní nástroje v oboru; pokud je neznáte, můžete použít tento tutoriál.

Většina serveru je napsána v JavaScriptu pomocí Node (opens in a new tab). Část s nulovou znalostí je napsána v jazyce Noir (opens in a new tab). Potřebujeme verzi 1.0.0-beta.10, takže po instalaci Noir podle pokynů (opens in a new tab) spusťte:

1noirup -v 1.0.0-beta.10

Blockchain, který používáme, je anvil, lokální testovací blockchain, který je součástí Foundry (opens in a new tab).

Implementace

Protože se jedná o složitý systém, budeme ho implementovat postupně.

Fáze 1 – Ruční nulová znalost

V první fázi podepíšeme transakci v prohlížeči a poté ručně poskytneme informace do důkazu s nulovou znalostí. Kód nulové znalosti očekává, že tyto informace získá v souboru server/noir/Prover.toml (zdokumentováno zde (opens in a new tab)).

Chcete-li to vidět v akci:

  1. Ujistěte se, že máte nainstalovaný Node (opens in a new tab) a Noir (opens in a new tab). Nejlépe je nainstalujte na systém UNIX, jako je macOS, Linux nebo WSL (opens in a new tab).

  2. Stáhněte si kód 1. fáze a spusťte webový server, který bude obsluhovat kód klienta.

    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

    Důvod, proč zde potřebujete webový server, je ten, že aby se předešlo určitým typům podvodů, mnoho peněženek (například MetaMask) nepřijímá soubory obsluhované přímo z disku.

  3. Otevřete prohlížeč s peněženkou.

  4. V peněžence zadejte novou heslovou frázi. Upozorňujeme, že tímto smažete stávající heslovou frázi, takže se ujistěte, že máte zálohu.

    Heslová fráze je test test test test test test test test test test test junk, výchozí testovací heslová fráze pro anvil.

  5. Přejděte na kód na straně klienta (opens in a new tab).

  6. Připojte se k peněžence a vyberte cílový účet a částku.

  7. Klikněte na Podepsat a podepište transakci.

  8. Pod nadpisem Prover.toml najdete text. Nahraďte soubor server/noir/Prover.toml tímto textem.

  9. Spusťte důkaz s nulovou znalostí.

    1cd ../server/noir
    2nargo execute

    Výstup by měl být podobný tomuto

    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)
  10. Porovnejte poslední dvě hodnoty s hashem, který vidíte ve webovém prohlížeči, abyste zjistili, zda je zpráva správně zahashována.

server/noir/Prover.toml

Tento soubor (opens in a new tab) ukazuje formát informací, který očekává Noir.

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

Zpráva je v textovém formátu, což usnadňuje její pochopení uživatelem (což je nutné při podepisování) a její zpracování kódem Noir. Částka je uvedena ve finney, aby bylo možné na jedné straně provádět zlomkové převody a na druhé straně byla snadno čitelná. Poslední číslo je nonce (opens in a new tab).

Řetězec je dlouhý 100 znaků. Důkazy s nulovou znalostí si dobře neporadí s daty s proměnlivou velikostí, proto je často nutné data doplňovat.

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

Tyto tři parametry jsou bajtová pole s pevnou velikostí.

1[[accounts]]
2address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
3balance=100_000
4nonce=0
5
6[[accounts]]
7address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
8balance=100_000
9nonce=0
Zobrazit vše

Tímto způsobem se specifikuje pole struktur. Pro každou položku zadáme adresu, zůstatek (v milliETH, známé také jako finney (opens in a new tab)) a další hodnotu nonce.

client/src/Transfer.tsx

Tento soubor (opens in a new tab) implementuje zpracování na straně klienta a generuje soubor server/noir/Prover.toml (ten, který obsahuje parametry nulové znalosti).

Zde je vysvětlení zajímavějších částí.

1export default attrs => {

Tato funkce vytváří komponentu Transfer Reactu, kterou mohou importovat další soubory.

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

Toto jsou adresy účtů, adresy vytvořené pomocí test ... heslové fráze test junk. Pokud chcete použít vlastní adresy, stačí upravit tuto definici.

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

Tyto Wagmi hooky (opens in a new tab) nám umožňují přístup ke knihovně viem (opens in a new tab) a k peněžence.

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

Toto je zpráva, doplněná mezerami. Pokaždé, když se změní jedna z proměnných useState (opens in a new tab), komponenta se překreslí a zpráva se aktualizuje.

1 const sign = async () => {

Tato funkce je volána, když uživatel klikne na tlačítko Podepsat. Zpráva se automaticky aktualizuje, ale podpis vyžaduje schválení uživatelem v peněžence a my o něj nechceme žádat, pokud to není nutné.

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

Požádejte peněženku o podepsání zprávy (opens in a new tab).

1 const hash = hashMessage(message)

Získejte hash zprávy. Je užitečné poskytnout ho uživateli pro ladění (kódu Noir).

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

Získejte veřejný klíč (opens in a new tab). To je nutné pro funkci Noir ecrecover (opens in a new tab).

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

Nastavte stavové proměnné. Tímto se komponenta překreslí (po ukončení funkce sign) a uživateli se zobrazí aktualizované hodnoty.

1 let proverToml = `

Text pro Prover.toml.

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

Viem nám poskytuje veřejný klíč jako 65bajtový hexadecimální řetězec. První bajt je 0x04, označení verze. Následuje 32 bajtů pro x veřejného klíče a poté 32 bajtů pro y veřejného klíče.

Noir však očekává, že tyto informace získá jako dvě bajtová pole, jedno pro x a jedno pro y. Je snazší ho analyzovat zde na straně klienta než v rámci důkazu s nulovou znalostí.

Všimněte si, že se obecně jedná o dobrou praxi v oblasti nulové znalosti. Kód uvnitř důkazu s nulovou znalostí je nákladný, takže jakékoli zpracování, které lze provést mimo důkaz s nulovou znalostí, by se mělo provádět mimo důkaz s nulovou znalostí.

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

Podpis je také poskytován jako 65bajtový hexadecimální řetězec. Poslední bajt je však nutný pouze k obnovení veřejného klíče. Protože veřejný klíč bude již poskytnut kódu Noir, nepotřebujeme ho k ověření podpisu a kód Noir ho nevyžaduje.

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

Poskytněte účty.

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

Toto je formát HTML (přesněji JSX (opens in a new tab)) komponenty.

server/noir/src/main.nr

Tento soubor (opens in a new tab) je skutečný kód nulové znalosti.

1use std::hash::pedersen_hash;

Pedersen hash (opens in a new tab) je poskytován se standardní knihovnou Noir (opens in a new tab). Důkazy s nulovou znalostí běžně používají tuto hashovací funkci. V aritmetických obvodech (opens in a new tab) se vypočítává mnohem snadněji než standardní hashovací funkce.

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

Tyto dvě funkce jsou externí knihovny definované v souboru Nargo.toml (opens in a new tab). Jsou přesně tím, po čem jsou pojmenovány: funkcí, která vypočítá hash keccak256 (opens in a new tab), a funkcí, která ověřuje podpisy Ethereum a obnovuje adresu Ethereum podepisujícího.

1global ACCOUNT_NUMBER : u32 = 5;

Noir je inspirován jazykem Rust (opens in a new tab). Proměnné jsou ve výchozím nastavení konstanty. Takto definujeme globální konfigurační konstanty. Konkrétně ACCOUNT_NUMBER je počet účtů, které ukládáme.

Datové typy s názvem u<číslo> mají daný počet bitů a jsou bez znaménka. Jediné podporované typy jsou u8, u16, u32, u64 a u128.

1global FLAT_ACCOUNT_FIELDS : u32 = 2;

Tato proměnná se používá pro Pedersen hash účtů, jak je vysvětleno níže.

1global MESSAGE_LENGTH : u32 = 100;

Jak bylo vysvětleno výše, délka zprávy je pevná. Je zde specifikována.

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) vyžadují vyrovnávací paměť s 26bajtovou předponou, za níž následuje délka zprávy v ASCII a nakonec samotná zpráva.

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

Informace, které ukládáme o účtu. Field (opens in a new tab) je číslo, typicky až 253 bitů, které lze použít přímo v aritmetickém obvodu (opens in a new tab), který implementuje důkaz s nulovou znalostí. Zde používáme Field k uložení 160bitové adresy Ethereum.

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

Informace, které ukládáme pro převodní transakci.

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

Definice funkce. Parametrem jsou informace o účtu. Výsledkem je pole proměnných Field, jejichž délka je FLAT_ACCOUNT_FIELDS.

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

První hodnota v poli je adresa účtu. Druhá zahrnuje jak zůstatek, tak nonce. Volání .into() změní číslo na datový typ, kterým má být. account.nonce je hodnota u32, ale aby ji bylo možné přičíst k hodnotě account.balance << 32, která je u128, musí být u128. To je první .into(). Druhý převádí výsledek u128 na Field, aby se vešel do pole.

1 flat
2}

V jazyce Noir mohou funkce vracet hodnotu pouze na konci (neexistuje předčasné vrácení). Chcete-li zadat návratovou hodnotu, vyhodnotíte ji těsně před uzavírací závorkou funkce.

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

Tato funkce převede pole účtů na pole Field, které lze použít jako vstup do Petersen Hash.

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

Takto se určuje měnitelná proměnná, tj. ne konstanta. Proměnné v Noir musí mít vždy hodnotu, proto tuto proměnnou inicializujeme na samé nuly.

1 for i in 0..ACCOUNT_NUMBER {

Toto je smyčka for. Všimněte si, že hranice jsou konstanty. Smyčky Noir musí mít své hranice známé v době kompilace. Důvodem je, že aritmetické obvody nepodporují řízení toku. Při zpracování smyčky for kompilátor jednoduše vloží kód dovnitř několikrát, jednou pro každou iteraci.

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}
Zobrazit vše

Nakonec jsme se dostali k funkci, která hashuje pole účtů.

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 }

Tato funkce najde účet se specifickou adresou. Tato funkce by byla ve standardním kódu strašně neefektivní, protože iteruje přes všechny účty, i když už našla adresu.

V důkazech s nulovou znalostí však neexistuje žádné řízení toku. Pokud někdy potřebujeme zkontrolovat podmínku, musíme ji zkontrolovat pokaždé.

Podobná věc se děje s příkazy if. Příkaz if ve smyčce výše je přeložen do těchto matematických příkazů.

výsledekpodmínky = účty[i].adresa == adresa // jedna, pokud se rovnají, jinak nula

účetnový = výsledekpodmínky*i + (1-výsledekpodmínky)*účetstarý

1 assert (account < ACCOUNT_NUMBER, f"{address} nemá účet");
2
3 account
4}

Funkce assert (opens in a new tab) způsobí pád důkazu s nulovou znalostí, pokud je tvrzení nepravdivé. V tomto případě, pokud nemůžeme najít účet s příslušnou adresou. K nahlášení adresy použijeme formátovací řetězec (opens in a new tab).

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

Tato funkce aplikuje převodní transakci a vrací nové pole účtů.

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

V Noir nemůžeme přistupovat k prvkům struktury uvnitř formátovacího řetězce, proto si vytvoříme použitelnou kopii.

1 assert (accounts[from].balance >= txn.amount,
2 f"{txnFrom} nemá {txnAmount} finney");
3
4 assert (accounts[from].nonce == txn.nonce,
5 f"Transakce má nonce {txnNonce}, ale očekává se, že účet použije {accountNonce}");

Toto jsou dvě podmínky, které by mohly způsobit neplatnost transakce.

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}

Vytvořte nové pole účtů a poté ho vraťte.

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

Tato funkce čte adresu ze zprávy.

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

Adresa má vždy 20 bajtů (tj. 40 hexadecimálních číslic) a začíná na znaku #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)
Zobrazit vše

Přečtěte částku a nonce ze zprávy.

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;

Ve zprávě je první číslo za adresou částka finney (tj. tisícina ETH) k převodu. Druhé číslo je nonce. Jakýkoli text mezi nimi je ignorován.

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 { // Právě jsme to našli
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}
Zobrazit vše

Vrácení n-tice (opens in a new tab) je v Noir způsob, jak vrátit více hodnot z funkce.

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}
Zobrazit vše

Tato funkce převede zprávu na bajty a poté převede částky na TransferTxn.

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

Pedersen Hash jsme mohli použít pro účty, protože se hashují pouze v rámci důkazu s nulovou znalostí. V tomto kódu však musíme zkontrolovat podpis zprávy, který je generován prohlížečem. K tomu je třeba dodržet formát podepisování Ethereum v EIP 191 (opens in a new tab). To znamená, že musíme vytvořit kombinovanou vyrovnávací paměť se standardní předponou, délkou zprávy v ASCII a samotnou zprávou a k jejímu hashování použít standardní keccak256 z Etherea.

1 // Předpona ASCII
2 let prefix_bytes = [
3 0x19, // \x19
4 0x45, // 'E'
5 0x74, // 't'
6 0x68, // 'h'
7 0x65, // 'e'
8 0x72, // 'r'
9 0x65, // 'e'
10 0x75, // 'u'
11 0x6D, // 'm'
12 0x20, // ' '
13 0x53, // 'S'
14 0x69, // 'i'
15 0x67, // 'g'
16 0x6E, // 'n'
17 0x65, // 'e'
18 0x64, // 'd'
19 0x20, // ' '
20 0x4D, // 'M'
21 0x65, // 'e'
22 0x73, // 's'
23 0x73, // 's'
24 0x61, // 'a'
25 0x67, // 'g'
26 0x65, // 'e'
27 0x3A, // ':'
28 0x0A // '\n'
29 ];
Zobrazit vše

Aby se předešlo případům, kdy aplikace požádá uživatele o podepsání zprávy, kterou lze použít jako transakci nebo pro jiný účel, EIP 191 stanoví, že všechny podepsané zprávy začínají znakem 0x19 (není to platný znak ASCII), za nímž následuje Ethereum Signed Message: a nový řádek.

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, "Zprávy s délkou přes tři číslice nejsou podporovány");
Zobrazit vše

Zpracujte délky zpráv až do 999 a selžete, pokud je větší. Tento kód jsem přidal, i když délka zprávy je konstanta, protože to usnadňuje její změnu. V produkčním systému byste pravděpodobně předpokládali, že se MESSAGE_LENGTH nemění kvůli lepšímu výkonu.

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

Použijte standardní funkci 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) // adresa, prvních 16 bajtů hashe, posledních 16 bajtů hashe
7{

Tato funkce ověřuje podpis, což vyžaduje hash zprávy. Poté nám poskytne adresu, která jej podepsala, a hash zprávy. Hash zprávy je dodáván ve dvou hodnotách Field, protože se s nimi ve zbytku programu snadněji pracuje než s bajtovým polem.

Musíme použít dvě hodnoty Field, protože výpočty pole se provádějí modulo (opens in a new tab) velkého čísla, ale toto číslo je obvykle menší než 256 bitů (jinak by bylo obtížné provádět tyto výpočty v EVM).

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

Určete hash1 a hash2 jako měnitelné proměnné a zapište do nich hash bajt po bajtu.

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

Je to podobné jako u ecrecover v Solidity (opens in a new tab), se dvěma důležitými rozdíly:

  • Pokud podpis není platný, volání selže s assert a program se přeruší.
  • Zatímco veřejný klíč lze obnovit z podpisu a hashe, jedná se o zpracování, které lze provést externě, a proto se nevyplatí ho provádět v rámci důkazu s nulovou znalostí. Pokud se nás zde někdo pokusí podvést, ověření podpisu se nezdaří.
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 pole starých účtů
14 Field, // Hash pole nových účtů
15 Field, // Prvních 16 bajtů hashe zprávy
16 Field, // Posledních 16 bajtů hashe zprávy
17 )
Zobrazit vše

Nakonec se dostáváme k funkci main. Musíme dokázat, že máme transakci, která platně mění hash účtů ze staré hodnoty na novou. Také musíme dokázat, že má tento specifický hash transakce, aby osoba, která ji odeslala, věděla, že její transakce byla zpracována.

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

Potřebujeme, aby txn byla měnitelná, protože adresu odesílatele nečteme ze zprávy, ale z podpisu.

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}
Zobrazit vše

Fáze 2 – Přidání serveru

Ve druhé fázi přidáme server, který přijímá a implementuje převodní transakce z prohlížeče.

Chcete-li to vidět v akci:

  1. Zastavte Vite, pokud běží.

  2. Stáhněte si větev, která obsahuje server, a ujistěte se, že máte všechny potřebné moduly.

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

    Není třeba kompilovat kód Noir, je to stejný kód, který jste použili pro 1. fázi.

  3. Spusťte server.

    1npm run start
  4. V samostatném okně příkazového řádku spusťte Vite, abyste mohli obsluhovat kód prohlížeče.

    1cd client
    2npm run dev
  5. Přejděte na klientský kód na adrese http://localhost:5173 (opens in a new tab)

  6. Než budete moci vydat transakci, musíte znát nonce a také částku, kterou můžete odeslat. Chcete-li získat tyto informace, klikněte na Aktualizovat údaje o účtu a podepište zprávu.

    Máme zde dilema. Na jedné straně nechceme podepisovat zprávu, kterou lze znovu použít (opakovací útok (opens in a new tab)), což je důvod, proč chceme mít nonce. Nicméně ještě nemáme nonce. Řešením je zvolit nonce, které lze použít pouze jednou a které již máme na obou stranách, například aktuální čas.

    Problém s tímto řešením je, že čas nemusí být dokonale synchronizován. Takže místo toho podepíšeme hodnotu, která se mění každou minutu. To znamená, že naše okno zranitelnosti vůči opakovacím útokům je maximálně jedna minuta. Vzhledem k tomu, že v produkci bude podepsaný požadavek chráněn protokolem TLS a že druhá strana tunelu – server – již může sdělit zůstatek a nonce (musí je znát, aby mohl fungovat), jedná se o přijatelné riziko.

  7. Jakmile prohlížeč získá zpět zůstatek a nonce, zobrazí formulář pro převod. Vyberte cílovou adresu a částku a klikněte na Převod. Podepište tento požadavek.

  8. Chcete-li zobrazit převod, buď Aktualizujte údaje o účtu, nebo se podívejte do okna, kde spouštíte server. Server protokoluje stav při každé změně.

    1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start
    2
    3> server@1.0.0 start
    4> node --experimental-json-modules index.mjs
    5
    6Listening on port 3000
    7Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 processed
    8New state:
    90xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 64000 (1)
    100x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 100000 (0)
    110x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)
    120x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)
    130x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
    14Txn send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 7200 finney (milliEth) 1 processed
    15New state:
    160xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 56800 (2)
    170x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)
    180x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)
    190x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)
    200x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
    21Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 3000 finney (milliEth) 2 processed
    22New state:
    230xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 53800 (3)
    240x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)
    250x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)
    260x90F79bf6EB2c4f870365E785982E1f101E93b906 has 139000 (0)
    270x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
    Zobrazit vše

server/index.mjs

Tento soubor (opens in a new tab) obsahuje proces serveru a interaguje s kódem Noir na main.nr (opens in a new tab). Zde je vysvětlení zajímavých částí.

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

Knihovna noir.js (opens in a new tab) propojuje kód JavaScriptu a kód Noir.

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

Načtěte aritmetický obvod – kompilovaný program Noir, který jsme vytvořili v předchozí fázi – a připravte se na jeho spuštění.

1// Informace o účtu poskytujeme pouze v odpovědi na podepsaný požadavek
2const accountInformation = async signature => {
3 const fromAddress = await recoverAddress({
4 hash: hashMessage("Získat data účtu " + Math.floor((new Date().getTime())/60000)),
5 signature
6 })

Pro poskytnutí informací o účtu potřebujeme pouze podpis. Důvodem je, že již víme, jaká bude zpráva, a tedy i hash zprávy.

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

Zpracujte zprávu a proveďte transakci, kterou kóduje.

1 // Získat veřejný klíč
2 const pubKey = await recoverPublicKey({
3 hash,
4 signature
5 })

Nyní, když spouštíme JavaScript na serveru, můžeme získat veřejný klíč tam, spíše než na klientovi.

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 })
Zobrazit vše

noir.execute spouští program Noir. Parametry jsou ekvivalentní těm, které jsou uvedeny v souboru Prover.toml (opens in a new tab). Všimněte si, že dlouhé hodnoty jsou poskytovány jako pole hexadecimálních řetězců (["0x60", "0xA7"]), nikoli jako jediná hexadecimální hodnota (0x60A7), jak to dělá Viem.

1 } catch (err) {
2 console.log(`Chyba Noir: ${err}`)
3 throw Error("Neplatná transakce, nebyla zpracována")
4 }

Pokud dojde k chybě, zachyťte ji a poté předejte zjednodušenou verzi klientovi.

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

Proveďte transakci. Už jsme to udělali v kódu Noir, ale je snazší to udělat znovu zde, než extrahovat výsledek odtamtud.

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

Počáteční struktura účtů.

Fáze 3 – Chytré kontrakty Ethereum

  1. Zastavte procesy serveru a klienta.

  2. Stáhněte si větev s chytrými kontrakty a ujistěte se, že máte všechny potřebné moduly.

    1git checkout 03-smart-contracts
    2cd client
    3npm install
    4cd ../server
    5npm install
  3. Spusťte anvil v samostatném okně příkazového řádku.

  4. Vygenerujte ověřovací klíč a ověřovač Solidity, poté zkopírujte kód ověřovače do projektu Solidity.

    1cd noir
    2bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak
    3bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol
    4cp target/Verifier.sol ../../smart-contracts/src
  5. Přejděte na chytré kontrakty a nastavte proměnné prostředí pro použití blockchainu anvil.

    1cd ../../smart-contracts
    2export ETH_RPC_URL=http://localhost:8545
    3ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
  6. Nasaďte Verifier.sol a uložte adresu do proměnné prostředí.

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

    Hodnota 0x199..67b je Pederson hash počátečního stavu Účtů. Pokud tento počáteční stav v server/index.mjs upravíte, můžete spustit transakci a zobrazit počáteční hash hlášený důkazem s nulovou znalostí.

  8. Spusťte server.

    1cd ../server
    2npm run start
  9. Spusťte klienta v jiném okně příkazového řádku.

    1cd client
    2npm run dev
  10. Spusťte nějaké transakce.

  11. Chcete-li ověřit, že se stav změnil na blockchainu, restartujte proces serveru. Podívejte se, že ZkBank již nepřijímá transakce, protože původní hodnota hashe v transakcích se liší od hodnoty hashe uložené na blockchainu.

    Toto je typ očekávané chyby.

    1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start
    2
    3> server@1.0.0 start
    4> node --experimental-json-modules index.mjs
    5
    6Listening on port 3000
    7Verification error: ContractFunctionExecutionError: The contract function "processTransaction" reverted with the following reason:
    8Wrong old state hash
    9
    10Contract Call:
    11 address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
    12 function: processTransaction(bytes _proof, bytes32[] _publicInputs)
    13 args: (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000)
    Zobrazit vše

server/index.mjs

Změny v tomto souboru se týkají především vytvoření skutečného důkazu a jeho odeslání na blockchain.

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

Musíme použít balíček Barretenberg (opens in a new tab) k vytvoření skutečného důkazu k odeslání na blockchain. Tento balíček můžeme použít buď spuštěním rozhraní příkazového řádku (bb), nebo použitím knihovny JavaScript, bb.js (opens in a new tab). Knihovna JavaScript je mnohem pomalejší než nativní spouštění kódu, takže zde používáme exec (opens in a new tab) pro použití příkazového řádku.

Všimněte si, že pokud se rozhodnete použít bb.js, musíte použít verzi, která je kompatibilní s verzí Noir, kterou používáte. V době psaní tohoto článku aktuální verze Noir (1.0.0-beta.11) používá bb.js verze 0.87.

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

Zde uvedená adresa je ta, kterou získáte, když začnete s čistým anvilem a budete postupovat podle výše uvedených pokynů.

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

Tento privátní klíč je jedním z výchozích předem financovaných účtů v anvil.

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

Vygenerujte důkaz pomocí spustitelného souboru bb.

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

Zapište svědka do souboru.

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

Vytvořte důkaz. Tento krok také vytvoří soubor s veřejnými proměnnými, ale ten nepotřebujeme. Tyto proměnné jsme již získali z noir.execute.

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

Důkaz je pole JSON hodnot Field, z nichž každá je reprezentována jako hexadecimální hodnota. Musíme ho však odeslat v transakci jako jedinou hodnotu bytes, kterou Viem reprezentuje velkým hexadecimálním řetězcem. Zde měníme formát zřetězením všech hodnot, odstraněním všech 0x a následným přidáním jednoho na konec.

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

Vyčistěte a vraťte důkaz.

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

Veřejná pole musí být pole 32bajtových hodnot. Jelikož jsme však potřebovali rozdělit hash transakce mezi dvě hodnoty Field, zobrazuje se jako 16bajtová hodnota. Zde přidáváme nuly, aby Viem pochopil, že se jedná o 32 bajtů.

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

Každá adresa používá každou nonce pouze jednou, takže můžeme použít kombinaci fromAddress a nonce jako jedinečný identifikátor pro soubor svědka a výstupní adresář.

1 try {
2 await zkBank.write.processTransaction([
3 proof, publicFields])
4 } catch (err) {
5 console.log(`Chyba ověření: ${err}`)
6 throw Error("Transakci nelze ověřit na blockchainu")
7 }
8 .
9 .
10 .
11}
Zobrazit vše

Odešlete transakci do řetězce.

smart-contracts/src/ZkBank.sol

Toto je onchain kód, který přijímá transakci.

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 }
Zobrazit vše

Kód na blockchainu musí sledovat dvě proměnné: ověřovač (samostatný kontrakt vytvořený nargem) a aktuální hash stavu.

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

Pokaždé, když se stav změní, vydáme událost TransactionProcessed.

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

Tato funkce zpracovává transakce. Získá důkaz (jako bajty) a veřejné vstupy (jako pole bytes32) ve formátu, který ověřovatel vyžaduje (aby se minimalizovalo zpracování na blockchainu a tím i náklady na gas).

1 require(_publicInputs[0] == currentStateHash,
2 "Špatný starý hash stavu");

Důkaz s nulovou znalostí musí být o tom, že transakce se mění z našeho současného hashe na nový.

1 myVerifier.verify(_proof, _publicFields);

Zavolejte kontrakt ověřovače, abyste ověřili důkaz s nulovou znalostí. Tento krok vrátí transakci, pokud je důkaz s nulovou znalostí nesprávný.

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

Pokud je vše v pořádku, aktualizujte hash stavu na novou hodnotu a vydejte událost TransactionProcessed.

Zneužití centralizovanou součástí

Informační bezpečnost se skládá ze tří atributů:

  • Důvěrnost, uživatelé nemohou číst informace, ke kterým nejsou oprávněni.
  • Integrita, informace nemohou být měněny jinak než oprávněnými uživateli oprávněným způsobem.
  • Dostupnost, oprávnění uživatelé mohou systém používat.

V tomto systému je integrita zajištěna prostřednictvím důkazů s nulovou znalostí. Dostupnost je mnohem obtížnější zaručit a důvěrnost je nemožná, protože banka musí znát zůstatek každého účtu a všechny transakce. Neexistuje způsob, jak zabránit entitě, která má informace, v jejich sdílení.

Možná by bylo možné vytvořit skutečně důvěrnou banku pomocí neviditelných adres (opens in a new tab), ale to je nad rámec tohoto článku.

Nepravdivé informace

Jedním ze způsobů, jak může server porušit integritu, je poskytnutí nepravdivých informací, když jsou požadována data (opens in a new tab).

K vyřešení tohoto problému můžeme napsat druhý program Noir, který přijímá účty jako soukromý vstup a adresu, pro kterou jsou informace požadovány, jako veřejný vstup. Výstupem je zůstatek a nonce této adresy a hash účtů.

Tento důkaz samozřejmě nelze ověřit na blockchainu, protože nechceme zveřejňovat nonce a zůstatky na blockchainu. Může však být ověřen klientským kódem spuštěným v prohlížeči.

Vynucené transakce

Obvyklým mechanismem pro zajištění dostupnosti a prevenci cenzury na L2 jsou vynucené transakce (opens in a new tab). Ale vynucené transakce se nekombinují s důkazy s nulovou znalostí. Server je jedinou entitou, která může ověřovat transakce.

Můžeme upravit smart-contracts/src/ZkBank.sol tak, aby přijímal vynucené transakce a zabránil serveru měnit stav, dokud nebudou zpracovány. To nás však vystavuje jednoduchému útoku typu denial-of-service. Co když je vynucená transakce neplatná, a proto ji nelze zpracovat?

Řešením je mít důkaz s nulovou znalostí, že vynucená transakce je neplatná. To dává serveru tři možnosti:

  • Zpracovat vynucenou transakci a poskytnout důkaz s nulovou znalostí, že byla zpracována, a nový hash stavu.
  • Odmítnout vynucenou transakci a poskytnout kontraktu důkaz s nulovou znalostí, že transakce je neplatná (neznámá adresa, špatné nonce nebo nedostatečný zůstatek).
  • Ignorovat vynucenou transakci. Neexistuje způsob, jak donutit server, aby transakci skutečně zpracoval, ale znamená to, že celý systém je nedostupný.

Dluhopisy dostupnosti

V reálné implementaci by pravděpodobně existoval nějaký druh motivace k zisku pro udržení serveru v provozu. Tuto pobídku můžeme posílit tím, že server zveřejní dluhopis dostupnosti, který může kdokoli spálit, pokud vynucená transakce není zpracována v určitém období.

Špatný kód Noir

Normálně, aby lidé důvěřovali chytrému kontraktu, nahrajeme zdrojový kód do prohlížeče bloků (opens in a new tab). V případě důkazů s nulovou znalostí to však nestačí.

Verifier.sol obsahuje ověřovací klíč, který je funkcí programu Noir. Tento klíč nám však neříká, jaký byl program Noir. Chcete-li mít skutečně důvěryhodné řešení, musíte nahrát program Noir (a verzi, která ho vytvořila). V opačném případě by důkazy s nulovou znalostí mohly odrážet jiný program, program se zadními vrátky.

Dokud nám prohlížeče bloků neumožní nahrávat a ověřovat programy Noir, měli byste to dělat sami (nejlépe na IPFS). Poté budou moci zkušení uživatelé stáhnout zdrojový kód, sami ho zkompilovat, vytvořit Verifier.sol a ověřit, že je identický s tím na blockchainu.

Závěr

Aplikace typu Plasma vyžadují centralizovanou komponentu jako úložiště informací. To otevírá potenciální zranitelnosti, ale na oplátku nám to umožňuje zachovat soukromí způsoby, které na samotném blockchainu nejsou dostupné. S důkazy s nulovou znalostí můžeme zajistit integritu a případně učinit ekonomicky výhodným, aby kdokoli, kdo provozuje centralizovanou komponentu, udržoval dostupnost.

Více z mé práce najdete zde (opens in a new tab).

Poděkování

  • Josh Crites si přečetl návrh tohoto článku a pomohl mi s ošemetným problémem Noir.

Za zbývající chyby jsem zodpovědný já.

Stránka naposledy aktualizována: 28. října 2025

Byl tento tutoriál užitečný?