Перейти до основного контенту

Написання специфічної для застосунку плазми, яка зберігає конфіденційність

нульове розголошення
сервер
поза ланцюжком
конфіденційність
Для досвідчених користувачів
Ori Pomerantz
15 жовтня 2025 р.
29 читається за хвилину

Вступ

На відміну від зведень, плазми використовують головну мережу Ethereum для цілісності, але не для доступності. У цій статті ми пишемо застосунок, який поводиться як плазма, де Ethereum гарантує цілісність (відсутність неавторизованих змін), але не доступність (централізований компонент може вийти з ладу й вимкнути всю систему).

Застосунок, який ми тут пишемо, — це банк, що зберігає конфіденційність. Різні адреси мають облікові записи з балансами, і вони можуть надсилати гроші (ETH) на інші облікові записи. Банк публікує хеші стану (облікові записи та їхні баланси) і транзакції, але зберігає фактичні баланси поза ланцюжком, де вони можуть залишатися приватними.

Проєкт

Це не готова до виробництва система, а навчальний інструмент. Тому вона написана з кількома спрощеними припущеннями.

  • Фіксований пул облікових записів. Існує певна кількість облікових записів, і кожен обліковий запис належить до заздалегідь визначеної адреси. Це робить систему набагато простішою, оскільки важко обробляти структури даних змінного розміру в доказах із нульовим розголошенням. Для готової до виробництва системи ми можемо використовувати корінь Меркла як хеш стану та надавати докази Меркла для необхідних балансів.

  • Зберігання в пам'яті. У виробничій системі нам потрібно записувати всі баланси облікових записів на диск, щоб зберегти їх у разі перезавантаження. Тут нічого страшного, якщо інформація просто втрачається.

  • Лише перекази. Виробнича система вимагала б спосіб внесення активів у банк та їхнього виведення. Але мета тут лише в тому, щоб проілюструвати концепцію, тому цей банк обмежений переказами.

Докази з нульовим розголошенням

На фундаментальному рівні доказ із нульовим розголошенням показує, що той, хто доводить, знає деякі дані, Dataprivate, такі що існує зв'язок Relationship між деякими публічними даними, Datapublic, і Dataprivate. Верифікатор знає Relationship та Datapublic.

Щоб зберегти конфіденційність, нам потрібно, щоб стани та транзакції були приватними. Але для забезпечення цілісності нам потрібно, щоб криптографічний хеш (opens in a new tab) станів був публічним. Щоб довести людям, які надсилають транзакції, що ці транзакції дійсно відбулися, нам також потрібно публікувати хеші транзакцій.

У більшості випадків Dataprivate є вхідними даними для програми доказу з нульовим розголошенням, а Datapublic — вихідними.

Ці поля в Dataprivate:

  • Staten, старий стан
  • Staten+1, новий стан
  • Transaction, транзакція, яка змінює старий стан на новий. Ця транзакція має містити такі поля:
    • Адреса призначення, яка отримує переказ
    • Сума переказу
    • Nonce, щоб гарантувати, що кожна транзакція може бути оброблена лише один раз. Адреса джерела не повинна бути в транзакції, оскільки її можна відновити з підпису.
  • Підпис, підпис, який уповноважений на виконання транзакції. У нашому випадку єдиною адресою, уповноваженою на виконання транзакції, є адреса джерела. Оскільки наша система нульового розголошення працює саме так, нам також потрібен публічний ключ облікового запису, на додаток до підпису Ethereum.

Це поля в Datapublic:

  • Hash(Staten) хеш старого стану
  • Hash(Staten+1) хеш нового стану
  • Hash(Transaction) хеш транзакції, яка змінює стан з Staten на Staten+1.

Зв'язок перевіряє кілька умов:

  • Публічні хеші дійсно є правильними хешами для приватних полів.
  • Транзакція, застосована до старого стану, призводить до нового стану.
  • Підпис походить від адреси джерела транзакції.

Через властивості криптографічних хеш-функцій доведення цих умов достатньо для забезпечення цілісності.

Структури даних

Основна структура даних — це стан, який утримується сервером. Для кожного облікового запису сервер відстежує баланс облікового запису та nonce (opens in a new tab), який використовується для запобігання атакам повторного відтворення (opens in a new tab).

Компоненти

Ця система потребує двох компонентів:

  • Сервер, який отримує транзакції, обробляє їх і публікує хеші в ланцюжку разом із доказами з нульовим розголошенням.
  • Смарт-контракт, який зберігає хеші та перевіряє докази з нульовим розголошенням, щоб гарантувати законність переходів стану.

Потоки даних і керування

Це способи, якими різні компоненти взаємодіють для переказу з одного облікового запису на інший.

  1. Веббраузер надсилає підписану транзакцію із запитом на переказ з облікового запису підписувача на інший обліковий запис.

  2. Сервер перевіряє, що транзакція є дійсною:

    • Підписувач має обліковий запис у банку з достатнім балансом.
    • Одержувач має обліковий запис у банку.
  3. Сервер обчислює новий стан, віднімаючи суму переказу від балансу підписувача та додаючи її до балансу одержувача.

  4. Сервер обчислює доказ із нульовим розголошенням, що зміна стану є дійсною.

  5. Сервер надсилає до Ethereum транзакцію, яка містить:

    • Новий хеш стану
    • Хеш транзакції (щоб відправник транзакції міг знати, що вона оброблена)
    • Доказ із нульовим розголошенням, який доводить, що перехід до нового стану є дійсним
  6. Смарт-контракт перевіряє доказ із нульовим розголошенням.

  7. Якщо доказ із нульовим розголошенням проходить перевірку, смарт-контракт виконує такі дії:

    • Оновити поточний хеш стану на новий хеш стану
    • Випустити запис у журналі з новим хешем стану та хешем транзакції

Інструменти

Для клієнтського коду ми будемо використовувати Vite (opens in a new tab), React (opens in a new tab), Viem (opens in a new tab) та Wagmi (opens in a new tab). Це стандартні інструменти; якщо ви з ними не знайомі, можете скористатися цим посібником.

Більша частина сервера написана на JavaScript з використанням Node (opens in a new tab). Частина з нульовим розголошенням написана на Noir (opens in a new tab). Нам потрібна версія 1.0.0-beta.10, тому після того, як ви встановите Noir згідно з інструкцією (opens in a new tab), виконайте:

1noirup -v 1.0.0-beta.10

Блокчейн, який ми використовуємо, — це anvil, локальний тестовий блокчейн, який є частиною Foundry (opens in a new tab).

Реалізація

Оскільки це складна система, ми будемо реалізовувати її поетапно.

Етап 1. Ручне нульове розголошення

На першому етапі ми підпишемо транзакцію в браузері, а потім вручну надамо інформацію для доказу з нульовим розголошенням. Код з нульовим розголошенням очікує отримати цю інформацію у файлі server/noir/Prover.toml (документація тут (opens in a new tab)).

Щоб побачити це в дії:

  1. Переконайтеся, що у вас встановлено Node (opens in a new tab) і Noir (opens in a new tab). Бажано встановлювати їх у системі UNIX, наприклад macOS, Linux або WSL (opens in a new tab).

  2. Завантажте код етапу 1 і запустіть вебсервер для обслуговування клієнтського коду.

    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

    Причина, через яку тут потрібен вебсервер, полягає в тому, що для запобігання певним видам шахрайства багато гаманців (наприклад, MetaMask) не приймають файли, що подаються безпосередньо з диска

  3. Відкрийте браузер із гаманцем.

  4. У гаманці введіть нову кодову фразу. Зверніть увагу, що це видалить вашу наявну кодову фразу, тому переконайтеся, що у вас є резервна копія.

    Кодова фраза — test test test test test test test test test test test junk, стандартна тестова кодова фраза для anvil.

  5. Перейдіть до клієнтського коду (opens in a new tab).

  6. Підключіться до гаманця та виберіть обліковий запис призначення та суму.

  7. Натисніть Підписати і підпишіть транзакцію.

  8. Під заголовком Prover.toml ви знайдете текст. Замініть server/noir/Prover.toml цим текстом.

  9. Виконайте доказ з нульовим розголошенням.

    1cd ../server/noir
    2nargo execute

    Вивід має бути схожий на

    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. Порівняйте два останні значення з хешем, який ви бачите у веббраузері, щоб перевірити, чи правильно захешовано повідомлення.

server/noir/Prover.toml

Цей файл (opens in a new tab) показує формат інформації, очікуваний Noir.

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

Повідомлення має текстовий формат, що полегшує його розуміння користувачем (що необхідно при підписанні) та аналіз кодом Noir. Сума вказана у фінні, щоб, з одного боку, уможливити дробові перекази, а з іншого — бути легко читабельною. Останнє число — це nonce (opens in a new tab).

Рядок має довжину 100 символів. Докази з нульовим розголошенням погано обробляють дані змінного розміру, тому часто необхідно доповнювати дані.

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

Ці три параметри є масивами байтів фіксованого розміру.

1[[accounts]]
2address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
3balance=100_000
4nonce=0
5
6[[accounts]]
7address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
8balance=100_000
9nonce=0
Показати все

Це спосіб вказати масив структур. Для кожного запису ми вказуємо адресу, баланс (в міліETH, також відомих як finney (opens in a new tab)), та наступне значення nonce.

client/src/Transfer.tsx

Цей файл (opens in a new tab) реалізує обробку на стороні клієнта та генерує файл server/noir/Prover.toml (той, що містить параметри нульового розголошення).

Ось пояснення найцікавіших частин.

1export default attrs => {

Ця функція створює компонент React Transfer, який можуть імпортувати інші файли.

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

Це адреси облікових записів, адреси, створені за допомогою test ... кодової фрази test junk`. Якщо ви хочете використовувати власні адреси, просто змініть це визначення.

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

Ці хуки Wagmi (opens in a new tab) дозволяють нам отримати доступ до бібліотеки viem (opens in a new tab) та гаманця.

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

Це повідомлення, доповнене пробілами. Щоразу, коли змінюється одна зі змінних useState (opens in a new tab), компонент перемальовується, а message оновлюється.

1 const sign = async () => {

Ця функція викликається, коли користувач натискає кнопку Підписати. Повідомлення оновлюється автоматично, але підпис потребує схвалення користувача в гаманці, і ми не хочемо запитувати його без потреби.

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

Попросіть гаманець підписати повідомлення (opens in a new tab).

1 const hash = hashMessage(message)

Отримати хеш повідомлення. Корисно надати його користувачеві для налагодження (коду Noir).

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

Отримати публічний ключ (opens in a new tab). Це необхідно для функції Noir ecrecover (opens in a new tab).

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

Встановити змінні стану. Це перемальовує компонент (після виходу з функції sign) і показує користувачеві оновлені значення.

1 let proverToml = `

Текст для Prover.toml.

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

Viem надає нам публічний ключ у вигляді 65-байтового шістнадцяткового рядка. Перший байт — це 0x04, маркер версії. Далі йдуть 32 байти для x публічного ключа, а потім 32 байти для y публічного ключа.

Однак Noir очікує отримати цю інформацію у вигляді двох байтових масивів, один для x і один для y. Це простіше розібрати тут, на клієнті, ніж як частину доказу з нульовим розголошенням.

Зауважте, що це є хорошою практикою в нульовому розголошенні загалом. Код всередині доказу з нульовим розголошенням є дорогим, тому будь-яка обробка, яку можна виконати поза доказом з нульовим розголошенням, повинна виконуватися поза ним.

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

Підпис також надається у вигляді 65-байтового шістнадцяткового рядка. Однак останній байт необхідний лише для відновлення публічного ключа. Оскільки публічний ключ уже буде надано коду Noir, нам не потрібно його для перевірки підпису, і код Noir його не вимагає.

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

Надайте облікові записи.

1 setProverToml(proverToml)
2 }
3
4 return (
5 <>
6 <h2>Переказ</h2>

Це формат HTML (точніше, JSX (opens in a new tab)) компонента.

server/noir/src/main.nr

Цей файл (opens in a new tab) є фактичним кодом з нульовим розголошенням.

1use std::hash::pedersen_hash;

Хеш Педерсена (opens in a new tab) надається зі стандартною бібліотекою Noir (opens in a new tab). Докази з нульовим розголошенням зазвичай використовують цю хеш-функцію. Його набагато простіше обчислити всередині арифметичних схем (opens in a new tab) порівняно зі стандартними хеш-функціями.

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

Ці дві функції є зовнішніми бібліотеками, визначеними в Nargo.toml (opens in a new tab). Вони є саме тим, за що їх названо: функція, яка обчислює хеш keccak256 (opens in a new tab), і функція, яка перевіряє підписи Ethereum і відновлює адресу Ethereum підписувача.

1global ACCOUNT_NUMBER : u32 = 5;

Noir натхненний Rust (opens in a new tab). Змінні за замовчуванням є константами. Так ми визначаємо глобальні константи конфігурації. Зокрема, ACCOUNT_NUMBER — це кількість облікових записів, які ми зберігаємо.

Типи даних з назвою u<число> — це беззнакові числа з такою кількістю бітів. Єдиними підтримуваними типами є u8, u16, u32, u64 та u128.

1global FLAT_ACCOUNT_FIELDS : u32 = 2;

Ця змінна використовується для хешу Педерсена облікових записів, як пояснено нижче.

1global MESSAGE_LENGTH : u32 = 100;

Як пояснено вище, довжина повідомлення фіксована. Вона вказана тут.

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

Підписи EIP-191 (opens in a new tab) вимагають буфер з 26-байтовим префіксом, за яким слідує довжина повідомлення в ASCII і, нарешті, саме повідомлення.

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

Інформація, яку ми зберігаємо про обліковий запис. Field (opens in a new tab) — це число, зазвичай до 253 біт, яке можна використовувати безпосередньо в арифметичній схемі (opens in a new tab), що реалізує доказ із нульовим розголошенням. Тут ми використовуємо Field для зберігання 160-бітової адреси Ethereum.

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

Інформація, яку ми зберігаємо для транзакції переказу.

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

Визначення функції. Параметр — це інформація Account. Результатом є масив змінних Field, довжина якого дорівнює FLAT_ACCOUNT_FIELDS

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

Перше значення в масиві — це адреса облікового запису. Друге включає як баланс, так і nonce. Виклики .into() змінюють число на тип даних, яким воно має бути. account.nonce є значенням u32, але щоб додати його до account.balance « 32, значення u128, воно має бути u128. Це перший .into(). Другий перетворює результат u128 на Field, щоб він вмістився в масив.

1 flat
2}

У Noir функції можуть повертати значення лише в кінці (немає раннього повернення). Щоб вказати значення, що повертається, ви обчислюєте його безпосередньо перед закриваючою дужкою функції.

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

Ця функція перетворює масив облікових записів на масив Field, який можна використовувати як вхідні дані для хешу Педерсена.

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

Так ви вказуєте змінну, що змінюється, тобто не константу. Змінні в Noir завжди повинні мати значення, тому ми ініціалізуємо цю змінну нулями.

1 for i in 0..ACCOUNT_NUMBER {

Це цикл for. Зауважте, що межі є константами. Цикли Noir повинні мати свої межі, відомі на етапі компіляції. Причина в тому, що арифметичні схеми не підтримують керування потоком. Під час обробки циклу for компілятор просто вставляє код всередині нього кілька разів, по одному на кожну ітерацію.

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}
Показати все

Нарешті, ми дійшли до функції, яка хешує масив облікових записів.

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 }

Ця функція знаходить обліковий запис за вказаною адресою. Ця функція була б жахливо неефективною в стандартному коді, оскільки вона перебирає всі облікові записи, навіть після того, як знайшла адресу.

Однак у доказах із нульовим розголошенням немає керування потоком. Якщо нам коли-небудь знадобиться перевірити умову, ми повинні перевіряти її кожного разу.

Подібне відбувається з операторами if. Оператор if у циклі вище перекладається в такі математичні вирази.

conditionresult = accounts[i].address == address // одиниця, якщо вони рівні, нуль в іншому випадку

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

1 assert (account < ACCOUNT_NUMBER, f"{address} не має облікового запису");
2
3 account
4}

Функція assert (opens in a new tab) призводить до збою доказу з нульовим розголошенням, якщо твердження є хибним. У цьому випадку, якщо ми не можемо знайти обліковий запис із відповідною адресою. Щоб повідомити адресу, ми використовуємо рядок форматування (opens in a new tab).

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

Ця функція застосовує транзакцію переказу та повертає новий масив облікових записів.

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

Ми не можемо отримати доступ до елементів структури всередині рядка форматування в Noir, тому ми створюємо придатну для використання копію.

1 assert (accounts[from].balance >= txn.amount,
2 f"{txnFrom} не має {txnAmount} фінні");
3
4 assert (accounts[from].nonce == txn.nonce,
5 f"Транзакція має nonce {txnNonce}, але очікується, що обліковий запис використовуватиме {accountNonce}");

Це дві умови, які можуть зробити транзакцію недійсною.

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}

Створити новий масив облікових записів, а потім повернути його.

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

Ця функція зчитує адресу з повідомлення.

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

Адреса завжди має довжину 20 байтів (також відомі як 40 шістнадцяткових цифр) і починається з 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)
Показати все

Зчитати суму та nonce з повідомлення.

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;

У повідомленні перше число після адреси — це сума у фінні (також відомі як тисячна частина ETH) для переказу. Друге число — це nonce. Будь-який текст між ними ігнорується.

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 { // Ми щойно його знайшли
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}
Показати все

Повернення кортежу (opens in a new tab) — це спосіб Noir повернути кілька значень з функції.

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}
Показати все

Ця функція перетворює повідомлення на байти, а потім перетворює суми на TransferTxn.

1// Еквівалент hashMessage від Viem
2// https://viem.sh/docs/utilities/hashMessage#hashmessage
3fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {

Ми могли використовувати хеш Педерсена для облікових записів, тому що вони хешуються лише всередині доказу з нульовим розголошенням. Однак у цьому коді нам потрібно перевірити підпис повідомлення, який генерується браузером. Для цього нам потрібно дотримуватися формату підпису Ethereum, визначеного в EIP 191 (opens in a new tab). Це означає, що нам потрібно створити комбінований буфер зі стандартним префіксом, довжиною повідомлення в ASCII та самим повідомленням, і використовувати стандартний для Ethereum keccak256 для його хешування.

1 // префікс 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 ];
Показати все

Щоб уникнути випадків, коли застосунок просить користувача підписати повідомлення, яке можна використати як транзакцію або для іншої мети, EIP 191 визначає, що всі підписані повідомлення починаються з символу 0x19 (недійсний символ ASCII), за яким слідує Ethereum Signed Message: та новий рядок.

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, "Повідомлення, довжина яких перевищує три цифри, не підтримуються");
Показати все

Обробляти довжину повідомлень до 999 і видавати помилку, якщо вона більша. Я додав цей код, хоча довжина повідомлення є константою, оскільки це полегшує його зміну. У виробничій системі ви, ймовірно, просто припустили б, що MESSAGE_LENGTH не змінюється заради кращої продуктивності.

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

Використовуйте стандартну функцію 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) // адреса, перші 16 байтів хешу, останні 16 байтів хешу
7{

Ця функція перевіряє підпис, для чого потрібен хеш повідомлення. Потім вона надає нам адресу, яка його підписала, та хеш повідомлення. Хеш повідомлення подається у двох значеннях Field, оскільки їх легше використовувати в решті програми, ніж байтовий масив.

Нам потрібно використовувати два значення Field, оскільки обчислення в полі виконуються за модулем (opens in a new tab) великого числа, але це число зазвичай менше 256 біт (інакше було б важко виконувати ці обчислення в 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 }

Вкажіть hash1 та hash2 як змінні, що змінюються, і запишіть в них хеш побайтово.

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

Це схоже на ecrecover у Solidity (opens in a new tab), але з двома важливими відмінностями:

  • Якщо підпис недійсний, виклик не проходить assert, і програма переривається.
  • Хоча публічний ключ можна відновити з підпису та хешу, це обробка, яку можна виконати ззовні, і тому не варто робити всередині доказу з нульовим розголошенням. Якщо хтось спробує нас тут обдурити, перевірка підпису не вдасться.
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, // Хеш старого масиву облікових записів
14 Field, // Хеш нового масиву облікових записів
15 Field, // Перші 16 байтів хешу повідомлення
16 Field, // Останні 16 байтів хешу повідомлення
17 )
Показати все

Нарешті, ми дійшли до функції main. Нам потрібно довести, що ми маємо транзакцію, яка дійсно змінює хеш облікових записів зі старого значення на нове. Нам також потрібно довести, що вона має цей конкретний хеш транзакції, щоб людина, яка її надіслала, знала, що її транзакція була оброблена.

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

Нам потрібно, щоб txn була змінною, тому що ми не зчитуємо адресу відправника з повідомлення, а зчитуємо її з підпису.

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}
Показати все

Етап 2. Додавання сервера

На другому етапі ми додаємо сервер, який отримує та реалізує транзакції переказу з браузера.

Щоб побачити це в дії:

  1. Зупиніть Vite, якщо він запущений.

  2. Завантажте гілку, яка містить сервер, і переконайтеся, що у вас є всі необхідні модулі.

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

    Немає потреби компілювати код Noir, він такий самий, як і код, який ви використовували на етапі 1.

  3. Запустіть сервер.

    1npm run start
  4. В окремому вікні командного рядка запустіть Vite для обслуговування коду браузера.

    1cd client
    2npm run dev
  5. Перейдіть до клієнтського коду за адресою http://localhost:5173 (opens in a new tab)

  6. Перш ніж ви зможете видати транзакцію, вам потрібно знати nonce, а також суму, яку ви можете надіслати. Щоб отримати цю інформацію, натисніть Оновити дані облікового запису та підпишіть повідомлення.

    Тут у нас дилема. З одного боку, ми не хочемо підписувати повідомлення, яке можна повторно використати (атака повторного відтворення (opens in a new tab)), і саме тому нам потрібен nonce. Однак у нас ще немає nonce. Рішення полягає в тому, щоб вибрати nonce, який можна використовувати лише один раз і який ми вже маємо з обох сторін, наприклад, поточний час.

    Проблема цього рішення полягає в тому, що час може бути не ідеально синхронізований. Тому замість цього ми підписуємо значення, яке змінюється щохвилини. Це означає, що наше вікно вразливості до атак повторного відтворення становить щонайбільше одну хвилину. Враховуючи, що у виробництві підписаний запит буде захищений TLS, і що інша сторона тунелю — сервер — уже може розкрити баланс і nonce (він повинен їх знати, щоб працювати), це є прийнятним ризиком.

  7. Коли браузер отримує баланс і nonce, він показує форму переказу. Виберіть адресу призначення та суму й натисніть Переказ. Підпишіть цей запит.

  8. Щоб побачити переказ, або Оновіть дані облікового запису, або подивіться у вікні, де ви запускаєте сервер. Сервер реєструє стан щоразу, коли він змінюється.

    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
    6Прослуховування порту 3000
    7Транзакція send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 оброблена
    8Новий стан:
    90xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 має 64000 (1)
    100x70997970C51812dc3A010C7d01b50e0d17dc79C8 має 100000 (0)
    110x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC має 100000 (0)
    120x90F79bf6EB2c4f870365E785982E1f101E93b906 має 136000 (0)
    130x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 має 100000 (0)
    14Транзакція send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 7200 finney (milliEth) 1 оброблена
    15Новий стан:
    160xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 має 56800 (2)
    170x70997970C51812dc3A010C7d01b50e0d17dc79C8 має 107200 (0)
    180x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC має 100000 (0)
    190x90F79bf6EB2c4f870365E785982E1f101E93b906 має 136000 (0)
    200x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 має 100000 (0)
    21Транзакція send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 3000 finney (milliEth) 2 оброблена
    22Новий стан:
    230xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 має 53800 (3)
    240x70997970C51812dc3A010C7d01b50e0d17dc79C8 має 107200 (0)
    250x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC має 100000 (0)
    260x90F79bf6EB2c4f870365E785982E1f101E93b906 має 139000 (0)
    270x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 має 100000 (0)
    Показати все

server/index.mjs

Цей файл (opens in a new tab) містить процес сервера та взаємодіє з кодом Noir у main.nr (opens in a new tab). Ось пояснення найцікавіших частин.

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

Бібліотека noir.js (opens in a new tab) забезпечує взаємодію між кодом JavaScript і кодом Noir.

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

Завантажте арифметичну схему — скомпільовану програму Noir, яку ми створили на попередньому етапі, — і підготуйтеся до її виконання.

1// Ми надаємо інформацію про обліковий запис лише у відповідь на підписаний запит
2const accountInformation = async signature => {
3 const fromAddress = await recoverAddress({
4 hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),
5 signature
6 })

Щоб надати інформацію про обліковий запис, нам потрібен лише підпис. Причина в тому, що ми вже знаємо, яким буде повідомлення, а отже, і хеш повідомлення.

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

Обробити повідомлення та виконати транзакцію, яку воно кодує.

1 // Отримати публічний ключ
2 const pubKey = await recoverPublicKey({
3 hash,
4 signature
5 })

Тепер, коли ми запускаємо JavaScript на сервері, ми можемо отримати публічний ключ там, а не на клієнті.

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 })
Показати все

noir.execute запускає програму Noir. Параметри еквівалентні тим, що надані в Prover.toml (opens in a new tab). Зауважте, що довгі значення надаються як масив шістнадцяткових рядків (["0x60", "0xA7"]), а не як єдине шістнадцяткове значення (0x60A7), як це робить Viem.

1 } catch (err) {
2 console.log(`Помилка Noir: ${err}`)
3 throw Error("Недійсна транзакція, не оброблено")
4 }

Якщо виникає помилка, перехопіть її, а потім передайте спрощену версію клієнту.

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

Застосувати транзакцію. Ми вже зробили це в коді Noir, але простіше зробити це знову тут, ніж витягувати результат звідти.

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

Початкова структура Accounts.

Етап 3. Смарт-контракти Ethereum

  1. Зупиніть процеси сервера та клієнта.

  2. Завантажте гілку зі смарт-контрактами та переконайтеся, що у вас є всі необхідні модулі.

    1git checkout 03-smart-contracts
    2cd client
    3npm install
    4cd ../server
    5npm install
  3. Запустіть anvil в окремому вікні командного рядка.

  4. Згенеруйте ключ верифікації та верифікатор Solidity, потім скопіюйте код верифікатора до проєкту 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. Перейдіть до смарт-контрактів і встановіть змінні середовища для використання блокчейну anvil.

    1cd ../../smart-contracts
    2export ETH_RPC_URL=http://localhost:8545
    3ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
  6. Розгорніть Verifier.sol і збережіть адресу в змінній середовища.

    1VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'`
    2echo $VERIFIER_ADDRESS
  7. Розгорніть контракт 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

    Значення 0x199..67b є хешем Педерсена початкового стану Accounts. Якщо ви зміните цей початковий стан у server/index.mjs, ви можете запустити транзакцію, щоб побачити початковий хеш, про який повідомляє доказ із нульовим розголошенням.

  8. Запустіть сервер.

    1cd ../server
    2npm run start
  9. Запустіть клієнт в іншому вікні командного рядка.

    1cd client
    2npm run dev
  10. Виконайте кілька транзакцій.

  11. Щоб перевірити, що стан змінився в ланцюжку, перезапустіть процес сервера. Подивіться, що ZkBank більше не приймає транзакції, оскільки початкове значення хешу в транзакціях відрізняється від значення хешу, що зберігається в ланцюжку.

    Це очікуваний тип помилки.

    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
    6Прослуховування порту 3000
    7Помилка верифікації: ContractFunctionExecutionError: Функція контракту "processTransaction" відхилена з наступної причини:
    8Неправильний старий хеш стану
    9
    10Виклик контракту:
    11 адреса: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
    12 функція: processTransaction(bytes _proof, bytes32[] _publicInputs)
    13 аргументи: (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000
    Показати все

server/index.mjs

Зміни в цьому файлі в основному стосуються створення фактичного доказу та його подання в ланцюжок.

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

Нам потрібно використовувати пакет Barretenberg (opens in a new tab) для створення фактичного доказу, який буде відправлено в ланцюжок. Ми можемо використовувати цей пакет або за допомогою інтерфейсу командного рядка (bb), або за допомогою бібліотеки JavaScript, bb.js (opens in a new tab). Бібліотека JavaScript набагато повільніша, ніж запуск коду нативно, тому ми використовуємо exec (opens in a new tab) тут, щоб використовувати командний рядок.

Зауважте, що якщо ви вирішите використовувати bb.js, вам потрібно використовувати версію, сумісну з версією Noir, яку ви використовуєте. На момент написання статті поточна версія Noir (1.0.0-beta.11) використовує bb.js версії 0.87.

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

Адреса тут — це та, яку ви отримуєте, коли починаєте з чистого anvil і дотримуєтеся наведених вище вказівок.

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

Цей приватний ключ є одним із стандартних попередньо профінансованих облікових записів в anvil.

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

Згенерувати доказ за допомогою виконуваного файлу bb.

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

Записати свідоцтво у файл.

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

Фактично створити доказ. Цей крок також створює файл із публічними змінними, але нам він не потрібен. Ми вже отримали ці змінні з noir.execute.

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

Доказ — це масив JSON значень Field, кожне з яких представлено шістнадцятковим значенням. Однак нам потрібно надіслати його в транзакції як єдине значення bytes, яке Viem представляє великим шістнадцятковим рядком. Тут ми змінюємо формат, конкатенуючи всі значення, видаляючи всі 0x і додаючи один в кінці.

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

Очищення та повернення доказу.

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

Публічні поля мають бути масивом 32-байтових значень. Однак, оскільки нам потрібно було розділити хеш транзакції між двома значеннями Field, він виглядає як 16-байтове значення. Тут ми додаємо нулі, щоб Viem зрозумів, що це насправді 32 байти.

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

Кожна адреса використовує кожен nonce лише один раз, тому ми можемо використовувати комбінацію fromAddress і nonce як унікальний ідентифікатор для файлу-свідка та вихідного каталогу.

1 try {
2 await zkBank.write.processTransaction([
3 proof, publicFields])
4 } catch (err) {
5 console.log(`Помилка верифікації: ${err}`)
6 throw Error("Не вдається перевірити транзакцію в ланцюжку")
7 }
8 .
9 .
10 .
11}
Показати все

Надіслати транзакцію до ланцюга.

smart-contracts/src/ZkBank.sol

Це код у ланцюжку, який отримує транзакцію.

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 }
Показати все

Код у ланцюжку повинен відстежувати дві змінні: верифікатор (окремий контракт, створений nargo) та поточний хеш стану.

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

Щоразу, коли стан змінюється, ми генеруємо подію TransactionProcessed.

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

Ця функція обробляє транзакції. Вона отримує доказ (як bytes) і публічні вхідні дані (як масив bytes32), у форматі, який вимагає верифікатор (для мінімізації обробки в ланцюжку і, отже, вартості газу).

1 require(_publicInputs[0] == currentStateHash,
2 "Неправильний старий хеш стану");

Доказ із нульовим розголошенням має доводити, що транзакція змінює наш поточний хеш на новий.

1 myVerifier.verify(_proof, _publicFields);

Викличте контракт-верифікатор для перевірки доказу з нульовим розголошенням. Цей крок відхиляє транзакцію, якщо доказ із нульовим розголошенням є хибним.

1 currentStateHash = _publicFields[1];
2
3 emit TransactionProcessed(
4 _publicFields[2]<<128 | _publicFields[3],
5 _publicFields[0],
6 _publicFields[1]
7 );
8 }
9}
Показати все

Якщо все перевірено, оновіть хеш стану на нове значення та генеруйте подію TransactionProcessed.

Зловживання з боку централізованого компонента

Інформаційна безпека складається з трьох атрибутів:

  • Конфіденційність, користувачі не можуть читати інформацію, на читання якої вони не уповноважені.
  • Цілісність, інформація не може бути змінена, окрім як уповноваженими користувачами уповноваженим чином.
  • Доступність, уповноважені користувачі можуть використовувати систему.

У цій системі цілісність забезпечується за допомогою доказів із нульовим розголошенням. Доступність набагато важче гарантувати, а конфіденційність неможлива, оскільки банк повинен знати баланс кожного облікового запису та всі транзакції. Немає способу запобігти тому, щоб суб'єкт, який володіє інформацією, ділився цією інформацією.

Можливо, можна створити справді конфіденційний банк за допомогою прихованих адрес (opens in a new tab), але це виходить за рамки цієї статті.

Неправдива інформація

Один зі способів, яким сервер може порушити цілісність, — це надання неправдивої інформації, коли запитуються дані (opens in a new tab).

Щоб вирішити цю проблему, ми можемо написати другу програму Noir, яка отримує облікові записи як приватні вхідні дані та адресу, для якої запитується інформація, як публічні вхідні дані. Виводом є баланс і nonce цієї адреси, а також хеш облікових записів.

Звісно, цей доказ не можна перевірити в ланцюжку, оскільки ми не хочемо публікувати nonces і баланси в ланцюжку. Однак його можна перевірити за допомогою клієнтського коду, що працює в браузері.

Примусові транзакції

Звичайним механізмом для забезпечення доступності та запобігання цензурі на L2 є примусові транзакції (opens in a new tab). Але примусові транзакції не поєднуються з доказами з нульовим розголошенням. Сервер — єдиний суб'єкт, який може перевіряти транзакції.

Ми можемо змінити smart-contracts/src/ZkBank.sol, щоб приймати примусові транзакції та запобігати зміні стану сервером, доки вони не будуть оброблені. Ми можемо змінити smart-contracts/src/ZkBank.sol для прийому примусових транзакцій і запобігання зміні стану сервером до їх обробки. Однак це відкриває нас для простої атаки типу «відмова в обслуговуванні».

Що, якщо примусова транзакція недійсна і, отже, її неможливо обробити? Рішення полягає в тому, щоб мати доказ із нульовим розголошенням, що примусова транзакція недійсна.

  • Це дає серверу три варіанти:
  • Обробити примусову транзакцію, надавши доказ із нульовим розголошенням, що вона була оброблена, і новий хеш стану.
  • Відхилити примусову транзакцію і надати контракту доказ із нульовим розголошенням, що транзакція недійсна (невідома адреса, поганий nonce або недостатній баланс). Ігнорувати примусову транзакцію.

Немає способу змусити сервер фактично обробити транзакцію, але це означає, що вся система недоступна.

Гарантії доступності

У реальній реалізації, ймовірно, був би якийсь мотив прибутку для підтримки роботи сервера.

Ми можемо посилити цей стимул, змусивши сервер розміщувати гарантію доступності, яку кожен може спалити, якщо примусова транзакція не буде оброблена протягом певного періоду.

Поганий код Noir

Зазвичай, щоб змусити людей довіряти смарт-контракту, ми завантажуємо вихідний код до оглядача блоків (opens in a new tab).

Однак у випадку з доказами з нульовим розголошенням цього недостатньо. Verifier.sol містить ключ верифікації, який є функцією програми Noir. Однак цей ключ не говорить нам, якою була програма Noir. Щоб дійсно мати довірене рішення, вам потрібно завантажити програму Noir (і версію, яка її створила).

В іншому випадку докази з нульовим розголошенням можуть відображати іншу програму, з бекдором. Поки оглядачі блоків не дозволять нам завантажувати та перевіряти програми Noir, ви повинні робити це самостійно (бажано в IPFS).

Висновок

Застосунки типу Plasma вимагають централізованого компонента для зберігання інформації. Це створює потенційні вразливості, але, натомість, дозволяє нам зберігати конфіденційність способами, недоступними на самому блокчейні. За допомогою доказів із нульовим розголошенням ми можемо забезпечити цілісність і, можливо, зробити економічно вигідним для того, хто керує централізованим компонентом, підтримувати доступність.

Більше моїх робіт дивіться тут (opens in a new tab).

Подяки

  • Джош Крайтс прочитав чернетку цієї статті та допоміг мені з каверзним питанням щодо Noir.

Відповідальність за будь-які помилки, що залишилися, лежить на мені.

Останні оновлення сторінки: 28 жовтня 2025 р.

Чи була ця інструкція корисною?