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

Використання нульового розголошення для секретного стану

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

На блокчейні немає секретів. Усе, що публікується в блокчейні, відкрито для читання всім. Це необхідно, оскільки блокчейн ґрунтується на тому, що будь-хто може його перевірити. Однак ігри часто покладаються на секретний стан. Наприклад, гра «Сапер»opens in a new tab не має абсолютно ніякого сенсу, якщо ви можете просто зайти в оглядач блокчейнів і побачити карту.

Найпростішим рішенням є використання серверного компонента для зберігання секретного стану. Однак причина, через яку ми використовуємо блокчейн, — це запобігання шахрайству з боку розробника гри. Нам потрібно забезпечити чесність серверного компонента. Сервер може надати хеш стану і використовувати докази з нульовим розголошенням, щоб довести, що стан, який використовується для розрахунку результату ходу, є правильним.

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

ІнструментМетаПеревірено на версії
Zokratesopens in a new tabДокази з нульовим розголошенням та їхня верифікація1.1.9
Typescriptopens in a new tabМова програмування як для сервера, так і для клієнта5.4.2
Nodeopens in a new tabЗапуск сервера20.18.2
Viemopens in a new tabЗв'язок із блокчейном2.9.20
MUDopens in a new tabКерування ончейн-даними2.0.12
Reactopens in a new tabІнтерфейс користувача клієнта18.2.0
Viteopens in a new tabОбслуговування клієнтського коду4.2.1

Приклад гри «Сапер»

«Сапер»opens in a new tab — це гра, що містить секретну карту з мінним полем. Гравець обирає копати в певному місці. Якщо в цьому місці є міна, гру закінчено. В іншому випадку гравець отримує кількість мін у восьми клітинках, що оточують це місце.

Цей застосунок написано з використанням MUDopens in a new tab, фреймворка, що дозволяє нам зберігати ончейн-дані за допомогою бази даних «ключ-значення»opens in a new tab та автоматично синхронізувати ці дані з офчейн-компонентами. Окрім синхронізації, MUD полегшує керування доступом і дозволяє іншим користувачам розширюватиopens in a new tab наш застосунок без дозволу.

Запуск прикладу «Сапер»

Щоб запустити приклад «Сапер»:

  1. Переконайтеся, що ви встановили необхідні компонентиopens in a new tab: Nodeopens in a new tab, Foundryopens in a new tab, gitopens in a new tab, pnpmopens in a new tab та mprocsopens in a new tab.

  2. Клонуйте репозиторій.

    1git clone https://github.com/qbzzt/20240901-secret-state.git
  3. Встановіть пакети.

    1cd 20240901-secret-state/
    2pnpm install
    3npm install -g mprocs

    Якщо Foundry було встановлено як частину pnpm install, вам потрібно перезапустити оболонку командного рядка.

  4. Скомпілюйте контракти

    1cd packages/contracts
    2forge build
    3cd ../..
  5. Запустіть програму (включно з блокчейном anvilopens in a new tab) і зачекайте.

    1mprocs

    Зверніть увагу, що запуск займає багато часу. Щоб побачити прогрес, спочатку за допомогою стрілки вниз прокрутіть до вкладки contracts, щоб побачити, як розгортаються контракти MUD. Коли ви отримаєте повідомлення Waiting for file changes…, контракти буде розгорнуто, а подальший прогрес відбуватиметься у вкладці server. Там ви чекаєте, поки не отримаєте повідомлення Verifier address: 0x.....

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

    Екран mprocs

    Якщо виникла проблема з mprocs, ви можете запустити чотири процеси вручну, кожен у своєму вікні командного рядка:

    • Anvil

      1cd packages/contracts
      2anvil --base-fee 0 --block-time 2
    • Контракти

      1cd packages/contracts
      2pnpm mud dev-contracts --rpc http://127.0.0.1:8545
    • Сервер

      1cd packages/server
      2pnpm start
    • Клієнт

      1cd packages/client
      2pnpm run dev
  6. Тепер ви можете перейти до клієнтаopens in a new tab, натиснути New Game і почати грати.

Таблиці

Нам потрібно кілька таблицьopens in a new tab в ончейні.

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

    • height: Висота мінного поля
    • width: Ширина мінного поля
    • numberOfBombs: Кількість бомб на кожному мінному полі
  • VerifierAddress: Ця таблиця також є синглтоном. Вона використовується для зберігання однієї частини конфігурації, адреси контракту верифікатора (verifier). Ми могли б помістити цю інформацію в таблицю Configuration, але її встановлює інший компонент, сервер, тому простіше помістити її в окрему таблицю.

  • PlayerGame: Ключем є адреса гравця. Дані:

    • gameId: 32-байтове значення, яке є хешем карти, на якій грає гравець (ідентифікатор гри).
    • win: логічне значення, яке вказує, чи виграв гравець.
    • lose: логічне значення, яке вказує, чи програв гравець.
    • digNumber: кількість успішних розкопок у грі.
  • GamePlayer: Ця таблиця містить зворотне відображення від gameId до адреси гравця.

  • Map: Ключ є кортежем із трьох значень:

    • gameId: 32-байтове значення, яке є хешем карти, на якій грає гравець (ідентифікатор гри).
    • Координата x
    • Координата y

    Значенням є одне число. Це 255, якщо було виявлено бомбу. В іншому випадку це кількість бомб навколо цього місця плюс один. Ми не можемо просто використовувати кількість бомб, тому що за замовчуванням усе сховище в EVM і всі значення рядків у MUD дорівнюють нулю. Нам потрібно розрізняти між "гравець тут ще не копав" і "гравець тут копав і виявив, що навколо немає бомб".

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

  • PendingGame: незадоволені запити на початок нової гри.
  • PendingDig: незадоволені запити на копання в певному місці в певній грі. Це офчейн-таблицяopens in a new tab, що означає, що вона не записується до сховища EVM, а доступна для читання лише в офчейні за допомогою подій.

Потоки виконання та даних

Ці потоки координують виконання між клієнтом, ончейн-компонентом і сервером.

Ініціалізація

Коли ви запускаєте mprocs, відбуваються наступні кроки:

  1. mprocsopens in a new tab запускає чотири компоненти:

  2. Пакет contracts розгортає контракти MUD, а потім запускає скрипт PostDeploy.s.solopens in a new tab. Цей скрипт встановлює конфігурацію. Код з GitHub визначає мінне поле розміром 10x5 з вісьмома мінамиopens in a new tab.

  3. Серверopens in a new tab починає з налаштування MUDopens in a new tab. Серед іншого, це активує синхронізацію даних, так що копія відповідних таблиць існує в пам'яті сервера.

  4. Сервер підписує функцію для виконання коли таблиця Configuration змінюєтьсяopens in a new tab. Ця функціяopens in a new tab викликається після виконання PostDeploy.s.sol і зміни таблиці.

  5. Коли функція ініціалізації сервера має конфігурацію, вона викликає zkFunctionsopens in a new tab для ініціалізації частини сервера з нульовим розголошенням. Це не може статися, доки ми не отримаємо конфігурацію, тому що функції з нульовим розголошенням повинні мати ширину та висоту мінного поля як константи.

  6. Після ініціалізації частини сервера з нульовим розголошенням наступним кроком є розгортання контракту верифікації з нульовим розголошенням у блокчейніopens in a new tab та встановлення адреси верифікатора в MUD.

  7. Нарешті, ми підписуємося на оновлення, щоб бачити, коли гравець запитує або почати нову груopens in a new tab, або копати в існуючій гріopens in a new tab.

Нова гра

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

  1. Якщо для цього гравця немає поточної гри, або є, але з нульовим gameId, клієнт відображає кнопку нової гриopens in a new tab. Коли користувач натискає цю кнопку, React запускає функцію newGameopens in a new tab.

  2. newGameopens in a new tab — це системний виклик. У MUD усі виклики маршрутизуються через контракт World, і в більшості випадків ви викликаєте <namespace>__<function name>. У цьому випадку виклик відбувається до app__newGame, який MUD потім маршрутизує до newGame в GameSystemopens in a new tab.

  3. Ончейн-функція перевіряє, що у гравця немає поточної гри, і якщо немає, додає запит до таблиці PendingGameopens in a new tab.

  4. Сервер виявляє зміну в PendingGame і запускає підписану функціюopens in a new tab. Ця функція викликає newGameopens in a new tab, яка, своєю чергою, викликає createGameopens in a new tab.

  5. Перше, що робить createGame, — це створює випадкову карту з відповідною кількістю мінopens in a new tab. Потім він викликає makeMapBordersopens in a new tab для створення карти з порожніми рамками, що необхідно для Zokrates. Нарешті, createGame викликає calculateMapHash, щоб отримати хеш карти, який використовується як ID гри.

  6. Функція newGame додає нову гру до gamesInProgress.

  7. Останнє, що робить сервер, — викликає app__newGameResponseopens in a new tab, який знаходиться в ончейні. Ця функція знаходиться в іншій System, ServerSystemopens in a new tab, щоб увімкнути контроль доступу. Контроль доступу визначається у файлі конфігурації MUDopens in a new tab, mud.config.tsopens in a new tab.

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

  8. Ончейн-компонент оновлює відповідні таблиці:

    • Створити гру в PlayerGame.
    • Встановити зворотне відображення в GamePlayer.
    • Видалити запит із PendingGame.
  9. Сервер ідентифікує зміну в PendingGame, але нічого не робить, оскільки wantsGameopens in a new tab є хибним.

  10. На клієнті gameRecordopens in a new tab встановлюється на запис PlayerGame для адреси гравця. Коли PlayerGame змінюється, gameRecord також змінюється.

  11. Якщо в gameRecord є значення, і гра не була виграна або програна, клієнт відображає картуopens in a new tab.

Копання

  1. Гравець натискає кнопку клітинки картиopens in a new tab, що викликає функцію digopens in a new tab. Ця функція викликає dig в ончейніopens in a new tab.

  2. Ончейн-компонент виконує низку перевірок на адекватністьopens in a new tab, і в разі успіху додає запит на копання до PendingDigopens in a new tab.

  3. Сервер виявляє зміну в PendingDigopens in a new tab. Якщо він дійснийopens in a new tab, він викликає код з нульовим розголошеннямopens in a new tab (пояснено нижче), щоб згенерувати як результат, так і доказ його дійсності.

  4. Серверopens in a new tab викликає digResponseopens in a new tab в ончейні.

  5. digResponse робить дві речі. По-перше, він перевіряє доказ із нульовим розголошеннямopens in a new tab. Потім, якщо доказ проходить перевірку, він викликає processDigResultopens in a new tab для фактичної обробки результату.

  6. processDigResult перевіряє, чи була гра програнаopens in a new tab або вигранаopens in a new tab, і оновлює Map, ончейн-картуopens in a new tab.

  7. Клієнт автоматично отримує оновлення та оновлює карту, що відображається гравцевіopens in a new tab, і, якщо це доречно, повідомляє гравцеві, чи це виграш, чи програш.

Використання Zokrates

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

Хешування карти

Ми можемо використовувати цей код JavaScriptopens in a new tab для реалізації Poseidonopens in a new tab, хеш-функції Zokrates, яку ми використовуємо. Однак, хоча це було б швидше, це також було б складніше, ніж просто використовувати для цього хеш-функцію Zokrates. Це посібник, тому код оптимізований для простоти, а не для продуктивності. Тому нам потрібні дві різні програми Zokrates, одна для обчислення хеша карти (hash), а інша для створення доказу з нульовим розголошенням результату розкопок у певному місці на карті (dig).

Хеш-функція

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

1import "hashes/poseidon/poseidon.zok" as poseidon;
2import "utils/pack/bool/pack128.zok" as pack128;

Ці два рядки імпортують дві функції зі стандартної бібліотеки Zokratesopens in a new tab. Перша функціяopens in a new tab — це хеш Poseidonopens in a new tab. Він приймає масив елементів fieldopens in a new tab і повертає field.

Елемент поля в Zokrates зазвичай має довжину менше 256 біт, але не набагато. Щоб спростити код, ми обмежуємо карту до 512 біт і хешуємо масив із чотирьох полів, і в кожному полі ми використовуємо лише 128 біт. Функція pack128opens in a new tab для цього перетворює масив із 128 бітів на field.

1 def hashMap(bool[${width+2}][${height+2}] map) -> field {

Цей рядок починає визначення функції. hashMap отримує один параметр під назвою map, двовимірний логічний масив bool(ean). Розмір карти становить width+2 на height+2 з причин, пояснених нижче.

Ми можемо використовувати ${width+2} та ${height+2}, оскільки програми Zokrates зберігаються в цьому застосунку як рядки-шаблониopens in a new tab. Код між ${ і } обробляється JavaScript, і таким чином програму можна використовувати для карт різних розмірів. Параметр карти має рамку шириною в одну клітинку по всьому периметру без будь-яких бомб, тому нам потрібно додати два до ширини та висоти.

Повернене значення — це field, що містить хеш.

1 bool[512] mut map1d = [false; 512];

Карта є двовимірною. Однак функція pack128 не працює з двовимірними масивами. Тому ми спочатку вирівнюємо карту в 512-байтовий масив, використовуючи map1d. За замовчуванням змінні Zokrates є константами, але нам потрібно присвоювати значення цьому масиву в циклі, тому ми визначаємо його як mutopens in a new tab.

Нам потрібно ініціалізувати масив, тому що в Zokrates немає undefined. Вираз [false; 512] означає масив із 512 значень falseopens in a new tab.

1 u32 mut counter = 0;

Нам також потрібен лічильник, щоб розрізняти біти, які ми вже заповнили в map1d, і ті, які ще ні.

1 for u32 x in 0..${width+2} {

Так ви оголошуєте цикл foropens in a new tab у Zokrates. Цикл for у Zokrates повинен мати фіксовані межі, тому що хоча він виглядає як цикл, компілятор насправді «розгортає» його. Вираз ${width+2} є константою часу компіляції, оскільки width встановлюється кодом TypeScript перед викликом компілятора.

1 for u32 y in 0..${height+2} {
2 map1d[counter] = map[x][y];
3 counter = counter+1;
4 }
5 }

Для кожного місця на карті помістіть це значення в масив map1d і збільште лічильник.

1 field[4] hashMe = [
2 pack128(map1d[0..128]),
3 pack128(map1d[128..256]),
4 pack128(map1d[256..384]),
5 pack128(map1d[384..512])
6 ];

pack128 для створення масиву з чотирьох значень field з map1d. У Zokrates array[a..b] означає зріз масиву, який починається з a і закінчується на b-1.

1 return poseidon(hashMe);
2}

Використовуйте poseidon для перетворення цього масиву в хеш.

Програма хешування

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

1${hashFragment}
2
3def main(bool[${width+2}][${height+2}] map) -> field {
4 return hashMap(map);
5}

Програма копання

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

1${hashFragment}
2
3// Кількість мін у місці (x, y)
4def map2mineCount(bool[${width+2}][${height+2}] map, u32 x, u32 y) -> u8 {
5 return if map[x+1][y+1] { 1 } else { 0 };
6}

Навіщо потрібна рамка карти

Докази з нульовим розголошенням використовують арифметичні схемиopens in a new tab, які не мають простого еквівалента оператора if. Натомість вони використовують еквівалент умовного оператораopens in a new tab. Якщо a може бути або нулем, або одиницею, ви можете обчислити if a { b } else { c } як ab+(1-a)c.

Через це оператор if у Zokrates завжди обчислює обидві гілки. Наприклад, якщо у вас є такий код:

1bool[5] arr = [false; 5];
2u32 index=10;
3return if index>4 { 0 } else { arr[index] }

Він видасть помилку, оскільки йому потрібно обчислити arr[10], навіть якщо це значення пізніше буде помножене на нуль.

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

1def main(private bool[${width+2}][${height+2}] map, u32 x, u32 y) -> (field, u8) {

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

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

1 return (hashMap(map),

Повернене значення тут — це кортеж, який містить масив хешів карти, а також результат копання.

1 if map2mineCount(map, x, y) > 0 { 0xFF } else {

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

1 map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
2 map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
3 map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
4 }
5 );
6}

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

Використання Zokrates з TypeScript

Zokrates має інтерфейс командного рядка, але в цій програмі ми використовуємо його в коді TypeScriptopens in a new tab.

Бібліотека, що містить визначення Zokrates, називається zero-knowledge.tsopens in a new tab.

1import { initialize as zokratesInitialize } from "zokrates-js"

Імпортуйте зв'язки Zokrates для JavaScriptopens in a new tab. Нам потрібна лише функція initializeopens in a new tab, оскільки вона повертає проміс, який розв'язується до всіх визначень Zokrates.

1export const zkFunctions = async (width: number, height: number) : Promise<any> => {

Подібно до самого Zokrates, ми також експортуємо лише одну функцію, яка також є асинхронноюopens in a new tab. Коли вона врешті-решт повертається, вона надає кілька функцій, як ми побачимо нижче.

1const zokrates = await zokratesInitialize()

Ініціалізуйте Zokrates, отримайте все необхідне з бібліотеки.

1const hashFragment = `
2 import "utils/pack/bool/pack128.zok" as pack128;
3 import "hashes/poseidon/poseidon.zok" as poseidon;
4 .
5 .
6 .
7 }
8 `
9
10const hashProgram = `
11 ${hashFragment}
12 .
13 .
14 .
15 `
16
17const digProgram = `
18 ${hashFragment}
19 .
20 .
21 .
22 `
Показати все

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

1const digCompiled = zokrates.compile(digProgram)
2const hashCompiled = zokrates.compile(hashProgram)

Тут ми компілюємо ці програми.

1// Створення ключів для верифікації з нульовим розголошенням.
2// У виробничій системі ви б хотіли використовувати церемонію налаштування.
3// (https://zokrates.github.io/toolbox/trusted_setup.html#initializing-a-phase-2-ceremony).
4const keySetupResults = zokrates.setup(digCompiled.program, "")
5const verifierKey = keySetupResults.vk
6const proverKey = keySetupResults.pk

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

Примітка: компіляція програм Zokrates і створення ключів — це повільні процеси. Немає потреби повторювати їх щоразу, лише коли змінюється розмір карти. У виробничій системі ви б зробили це один раз, а потім зберегли б результат. Єдина причина, чому я не роблю цього тут, — це простота.

calculateMapHash

1const calculateMapHash = function (hashMe: boolean[][]): string {
2 return (
3 "0x" +
4 BigInt(zokrates.computeWitness(hashCompiled, [hashMe]).output.slice(1, -1))
5 .toString(16)
6 .padStart(64, "0")
7 )
8}

Функція computeWitnessopens in a new tab фактично запускає програму Zokrates. Вона повертає структуру з двома полями: output, який є виводом програми у вигляді рядка JSON, і witness, який є інформацією, необхідною для створення доказу з нульовим розголошенням результату. Тут нам потрібен лише вивід.

Вивід — це рядок вигляду "31337", десяткове число в лапках. Але вивід, який нам потрібен для viem, — це шістнадцяткове число вигляду 0x60A7. Отже, ми використовуємо .slice(1,-1), щоб видалити лапки, а потім BigInt, щоб перетворити решту рядка, який є десятковим числом, на BigIntopens in a new tab. .toString(16) перетворює цей BigInt на шістнадцятковий рядок, а "0x"+ додає маркер для шістнадцяткових чисел.

1// Викопати та повернути доказ з нульовим розголошенням результату
2// (код на стороні сервера)

Доказ із нульовим розголошенням містить публічні вхідні дані (x і y) та результати (хеш карти та кількість бомб).

1 const zkDig = function(map: boolean[][], x: number, y: number) : any {
2 if (x<0 || x>=width || y<0 || y>=height)
3 throw new Error("Trying to dig outside the map")

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

1const runResults = zokrates.computeWitness(digCompiled, [map, `${x}`, `${y}`])

Виконати програму копання.

1 const proof = zokrates.generateProof(
2 digCompiled.program,
3 runResults.witness,
4 proverKey)
5
6 return proof
7 }

Використовуйте generateProofopens in a new tab і поверніть доказ.

1const solidityVerifier = `
2 // Розмір карти: ${width} x ${height}
3 \n${zokrates.exportSolidityVerifier(verifierKey)}
4 `

Верифікатор Solidity, смартконтракт, який ми можемо розгорнути в блокчейні та використовувати для верифікації доказів, згенерованих digCompiled.program.

1 return {
2 zkDig,
3 calculateMapHash,
4 solidityVerifier,
5 }
6}

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

Тести безпеки

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

Дозволи

У цій грі є одна привілейована сутність — сервер. Це єдиний користувач, якому дозволено викликати функції в ServerSystemopens in a new tab. Ми можемо використовувати castopens in a new tab для перевірки того, що виклики до функцій з обмеженим доступом дозволені лише з облікового запису сервера.

Приватний ключ сервера знаходиться в setupNetwork.tsopens in a new tab.

  1. На комп'ютері, де запущено anvil (блокчейн), встановіть ці змінні середовища.

    1WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b
    2UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
    3AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
  2. Використовуйте cast для спроби встановити адресу верифікатора як неавторизовану адресу.

    1cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $UNAUTHORIZED_KEY

    Не тільки cast повідомляє про помилку, але ви можете відкрити MUD Dev Tools у грі в браузері, натиснути Tables і вибрати app__VerifierAddress. Переконайтеся, що адреса не є нульовою.

  3. Встановіть адресу верифікатора як адресу сервера.

    1cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $AUTHORIZED_KEY

    Адреса в app__VerifiedAddress тепер має бути нульовою.

Усі функції MUD в одній System проходять через однаковий контроль доступу, тому я вважаю цей тест достатнім. Якщо ви так не вважаєте, ви можете перевірити інші функції в ServerSystemopens in a new tab.

Зловживання з нульовим розголошенням

Математика для перевірки Zokrates виходить за рамки цього посібника (і моїх можливостей). Однак ми можемо виконати різні перевірки коду з нульовим розголошенням, щоб переконатися, що якщо він не виконаний правильно, він не спрацює. Усі ці тести вимагатимуть від нас зміни zero-knowledge.tsopens in a new tab і перезапуску всього застосунку. Недостатньо перезапустити процес сервера, оскільки це ставить застосунок у неможливий стан (у гравця є незакінчена гра, але ця гра більше не доступна для сервера).

Неправильна відповідь

Найпростіша можливість — надати неправильну відповідь у доказі з нульовим розголошенням. Для цього ми заходимо всередину zkDig і змінюємо рядок 91opens in a new tab:

1proof.inputs[3] = "0x" + "1".padStart(64, "0")

Це означає, що ми завжди будемо стверджувати, що є одна бомба, незалежно від правильної відповіді. Спробуйте пограти з цією версією, і ви побачите на вкладці server екрана pnpm dev таку помилку:

1 cause: {
2 code: 3,
3 message: 'execution reverted: revert: Zero knowledge verification fail',
4 data: '0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000
5000000000000000000000000000000000000000000000000205a65726f206b6e6f776c6564676520766572696669636174696f6
6e206661696c'
7 },

Отже, такий вид шахрайства не спрацьовує.

Неправильний доказ

Що станеться, якщо ми надамо правильну інформацію, але просто матимемо неправильні дані доказу? Тепер замініть рядок 91 на:

1proof.proof = {
2 a: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
3 b: [
4 ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
5 ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
6 ],
7 c: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
8}

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

Як користувач може перевірити код із нульовим розголошенням?

Смартконтракти відносно легко перевірити. Зазвичай розробник публікує вихідний код у оглядачі блоків, і оглядач блоків перевіряє, що вихідний код компілюється в код у транзакції розгортання контракту. У випадку Systems MUD це трохи складнішеopens in a new tab, але не набагато.

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

1 function verifyingKey() pure internal returns (VerifyingKey memory vk) {
2 vk.alpha = Pairing.G1Point(uint256(0x0f43f4fe7b5c2326fed4ac6ed2f4003ab9ab4ea6f667c2bdd77afb068617ee16), uint256(0x25a77832283f9726935219b5f4678842cda465631e72dbb24708a97ba5d0ce6f));
3 vk.beta = Pairing.G2Point([uint256(0x2cebd0fbd21aca01910581537b21ae4fed46bc0e524c055059aa164ba0a6b62b), uint256(0x18fd4a7bc386cf03a95af7163d5359165acc4e7961cb46519e6d9ee4a1e2b7e9)], [uint256(0x11449dee0199ef6d8eebfe43b548e875c69e7ce37705ee9a00c81fe52f11a009), uint256(0x066d0c83b32800d3f335bb9e8ed5e2924cf00e77e6ec28178592eac9898e1a00)]);

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

Для цього:

  1. Встановіть Zokratesopens in a new tab.

  2. Створіть файл dig.zok з програмою Zokrates. Код нижче передбачає, що ви зберегли початковий розмір карти, 10x5.

    1 import "utils/pack/bool/pack128.zok" as pack128;
    2 import "hashes/poseidon/poseidon.zok" as poseidon;
    3
    4 def hashMap(bool[12][7] map) -> field {
    5 bool[512] mut map1d = [false; 512];
    6 u32 mut counter = 0;
    7
    8 for u32 x in 0..12 {
    9 for u32 y in 0..7 {
    10 map1d[counter] = map[x][y];
    11 counter = counter+1;
    12 }
    13 }
    14
    15 field[4] hashMe = [
    16 pack128(map1d[0..128]),
    17 pack128(map1d[128..256]),
    18 pack128(map1d[256..384]),
    19 pack128(map1d[384..512])
    20 ];
    21
    22 return poseidon(hashMe);
    23 }
    24
    25
    26 // Кількість мін у місці (x,y)
    27 def map2mineCount(bool[12][7] map, u32 x, u32 y) -> u8 {
    28 return if map[x+1][y+1] { 1 } else { 0 };
    29 }
    30
    31 def main(private bool[12][7] map, u32 x, u32 y) -> (field, u8) {
    32 return (hashMap(map) ,
    33 if map2mineCount(map, x, y) > 0 { 0xFF } else {
    34 map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
    35 map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
    36 map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
    37 }
    38 );
    39 }
    Показати все
  3. Скомпілюйте код Zokrates і створіть ключ верифікації. Ключ верифікації має бути створений з тією ж ентропією, що й у вихідному сервері, у цьому випадку — порожній рядокopens in a new tab.

    1zokrates compile --input dig.zok
    2zokrates setup -e ""
  4. Створіть верифікатор Solidity самостійно і переконайтеся, що він функціонально ідентичний тому, що знаходиться в блокчейні (сервер додає коментар, але це неважливо).

    1zokrates export-verifier
    2diff verifier.sol ~/20240901-secret-state/packages/contracts/src/verifier.sol

Рішення щодо дизайну

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

Чому нульове розголошення?

Для гри «Сапер» вам насправді не потрібне нульове розголошення. Сервер завжди може тримати карту, а потім просто розкрити її всю, коли гра закінчиться. Тоді, наприкінці гри, смартконтракт може обчислити хеш карти, перевірити, чи він збігається, і якщо ні — покарати сервер або повністю проігнорувати гру.

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

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

Чому Zokrates?

Zokratesopens in a new tab — не єдина доступна бібліотека з нульовим розголошенням, але вона схожа на звичайну, імперативнуopens in a new tab мову програмування та підтримує логічні змінні.

Для вашого застосунку з іншими вимогами ви можете віддати перевагу використанню Circumopens in a new tab або Cairoopens in a new tab.

Коли компілювати Zokrates

У цій програмі ми компілюємо програми Zokrates кожного разу, коли запускається серверopens in a new tab. Це, безумовно, марна трата ресурсів, але це посібник, оптимізований для простоти.

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

Створення ключів верифікатора та доказувача

Створення ключівopens in a new tab — це ще одне чисте обчислення, яке не потрібно виконувати більше одного разу для даного розміру мінного поля. Знову ж таки, це робиться лише один раз заради простоти.

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

Тут ми покладаємося на perpetual powers of tauopens in a new tab, у яких були десятки учасників. Це, ймовірно, достатньо безпечно і набагато простіше. Ми також не додаємо ентропію під час створення ключа, що полегшує користувачам перевірку конфігурації з нульовим розголошенням.

Де перевіряти

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

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

Вирівнювати карту в TypeScript чи Zokrates?

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

Однак ми все одно вирівнюємо карту в Zokratesopens in a new tab, тоді як могли б зробити це в TypeScript. Причина в тому, що інші варіанти, на мою думку, гірші.

  • Надати одновимірний масив логічних значень коду Zokrates і використовувати вираз, такий як x*(height+2) +y, щоб отримати двовимірну карту. Це зробило б кодopens in a new tab дещо складнішим, тому я вирішив, що приріст продуктивності не вартий цього для посібника.

  • Надіслати Zokrates і одновимірний, і двовимірний масив. Однак це рішення нічого нам не дає. Код Zokrates мав би перевіряти, чи наданий одновимірний масив дійсно є правильним представленням двовимірного масиву. Тож приросту продуктивності не було б.

  • Вирівняти двовимірний масив у Zokrates. Це найпростіший варіант, тому я його вибрав.

Де зберігати карти

У цьому застосунку gamesInProgressopens in a new tab — це просто змінна в пам'яті. Це означає, що якщо ваш сервер вийде з ладу і потребуватиме перезапуску, вся збережена в ньому інформація буде втрачена. Гравці не тільки не зможуть продовжити свою гру, вони навіть не зможуть розпочати нову гру, оскільки ончейн-компонент вважає, що у них все ще є незавершена гра.

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

Висновок: за яких умов ця техніка є доречною?

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

  • Довготривала гра: як згадувалося вище, у короткій грі ви можете просто опублікувати стан, коли гра закінчиться, і все перевірити тоді. Але це не варіант, коли гра триває довго або невизначений час, і стан повинен залишатися секретним.

  • Деяка централізація є прийнятною: докази з нульовим розголошенням можуть перевіряти цілісність, тобто те, що суб'єкт не підробляє результати. Чого вони не можуть зробити, так це гарантувати, що суб'єкт все ще буде доступним і відповідатиме на повідомлення. У ситуаціях, коли доступність також потребує децентралізації, докази з нульовим розголошенням не є достатнім рішенням, і вам потрібні багатосторонні обчисленняopens in a new tab.

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

Подяки

  • Альваро Алонсо прочитав чернетку цієї статті та роз'яснив деякі з моїх непорозумінь щодо Zokrates.

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

Останні оновлення сторінки: 14 лютого 2026 р.

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