Přejít 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:

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

    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
    

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

    cd ../server/noir
    nargo execute
    

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

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

message="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.

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

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

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

export default attrs =>  {

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

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

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

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

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.

  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.

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

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

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

    const hash = hashMessage(message)

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

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

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

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

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

    let proverToml = `

Text pro Prover.toml.

message="${message}"

pubKeyX=${hexToArray(pubKey.slice(4,4+2*32))}
pubKeyY=${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í.

signature=${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.

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

Poskytněte účty.

    setProverToml(proverToml)
  }

  return (
    <>
        <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.

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

use keccak256::keccak256;
use 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.

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

global FLAT_ACCOUNT_FIELDS : u32 = 2;

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

global MESSAGE_LENGTH : u32 = 100;

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

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

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

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.

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

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

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

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

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.

    flat
}

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.

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

    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.

    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.

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

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

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ý

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

    account
}

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

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

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

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

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

    assert (accounts[from].balance >= txn.amount,
        f"{txnFrom} nemá {txnAmount} finney");

    assert (accounts[from].nonce == txn.nonce,
        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.

    let mut newAccounts = accounts;

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

    newAccounts
}

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

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

Tato funkce čte adresu ze zprávy.

{
    let mut result : Field = 0;

    for i in 7..47 {

Adresa má vždy 20 bajtů (tj. 40 hexadecimálních číslic) a začíná na znaku #7.

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

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

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.

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

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

// Ekvivalent hashMessage od Viem
// https://viem.sh/docs/utilities/hashMessage#hashmessage
fn 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.

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.

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.

    keccak256::keccak256(buffer, HASH_BUFFER_SIZE)
}

Použijte standardní funkci Ethereum keccak256.

fn signatureToAddressAndHash(
        message: str<MESSAGE_LENGTH>, 
        pubKeyX: [u8; 32],
        pubKeyY: [u8; 32],
        signature: [u8; 64]
    ) -> (Field, Field, Field)   // adresa, prvních 16 bajtů hashe, posledních 16 bajtů hashe        
{

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

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

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

    (
        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ří.

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.

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

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.

    git checkout 02-add-server
    cd client
    npm install
    cd ../server
    npm 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.

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

    cd client
    npm 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ě.

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

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

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

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

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

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.

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

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

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

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

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.

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

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

    Accounts[fromAccountNumber].nonce++
    Accounts[fromAccountNumber].balance -= amount
    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.

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

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.

    git checkout 03-smart-contracts
    cd client
    npm install
    cd ../server
    npm 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.

    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. Přejděte na chytré kontrakty a nastavte proměnné prostředí pro použití blockchainu anvil.

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

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

    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.

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

    cd client
    npm 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.

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.

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

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

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

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

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

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

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

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

Zapište svědka do souboru.

    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.

    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.

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

    return proof
}

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

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

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

    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ář.

Odešlete transakci do řetězce.

smart-contracts/src/ZkBank.sol

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

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

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

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

    function processTransaction(
        bytes calldata _proof,
        bytes32[] calldata _publicFields
    ) 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).

        require(_publicInputs[0] == currentStateHash,
            "Š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ý.

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

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

Poslední aktualizace stránky: 3. března 2026

Byl tento návod užitečný?