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

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

сервер
позамережевий
централізований
нульове розголошення
zokrates
mud
конфіденційність
Просунутий рівень
Орі Померанц
15 березня 2025 р.
25 хвилин на читання

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

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

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

ІнструментПризначенняПеревірено на версії
Zokrates (opens in a new tab)Доведення з нульовим розголошенням та їх верифікація1.1.9
TypeScript (opens in a new tab)Мова програмування для сервера та клієнта5.4.2
Node (opens in a new tab)Запуск сервера20.18.2
Viem (opens in a new tab)Зв'язок із блокчейном2.9.20
MUD (opens in a new tab)Управління ончейн-даними2.0.12
React (opens in a new tab)Інтерфейс користувача клієнта18.2.0
Vite (opens in a new tab)Обслуговування клієнтського коду4.2.1

Приклад Minesweeper

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

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

Запуск прикладу Minesweeper

Щоб запустити приклад Minesweeper:

  1. Переконайтеся, що у вас встановлено необхідне програмне забезпечення (opens in a new tab): Node (opens in a new tab), Foundry (opens in a new tab), git (opens in a new tab), pnpm (opens in a new tab) та mprocs (opens in a new tab).

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

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

    cd 20240901-secret-state/
    pnpm install
    npm install -g mprocs
    

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

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

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

    mprocs
    

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

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

    The mprocs screen

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

    • Anvil

      cd packages/contracts
      anvil --base-fee 0 --block-time 2
      
    • Contracts

      cd packages/contracts
      pnpm mud dev-contracts --rpc http://127.0.0.1:8545
      
    • Server

      cd packages/server
      pnpm start
      
    • Client

      cd packages/client
      pnpm 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. mprocs (opens in a new tab) запускає чотири компоненти:

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

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

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

  5. Коли функція ініціалізації сервера отримує конфігурацію, вона викликає zkFunctions (opens 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 запускає функцію newGame (opens in a new tab).

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

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

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

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

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

  7. Останнє, що робить сервер, це викликає app__newGameResponse (opens in a new tab), яка є ончейн. Ця функція знаходиться в іншому System, ServerSystem (opens in a new tab), щоб увімкнути контроль доступу. Контроль доступу визначається у файлі конфігурації MUD (opens in a new tab), mud.config.ts (opens in a new tab).

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

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

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

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

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

Копання

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

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

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

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

  5. digResponse робить дві речі. Спочатку він перевіряє доведення з нульовим розголошенням (opens in a new tab). Потім, якщо доведення проходить перевірку, він викликає processDigResult (opens 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

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

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

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

Хеш-функція

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

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

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

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

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, яке містить хеш.

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

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

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

u32 mut counter = 0;

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

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

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

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

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

field[4] hashMe = [
        pack128(map1d[0..128]),
        pack128(map1d[128..256]),
        pack128(map1d[256..384]),
        pack128(map1d[384..512])
    ];

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

return poseidon(hashMe);
}

Використайте poseidon, щоб перетворити цей масив на хеш.

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

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

${hashFragment}

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

Програма розкопок

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

${hashFragment}

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

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

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

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

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

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

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

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

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

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

return (hashMap(map),

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

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

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

map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
            map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
            map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
         }
   );
}

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

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

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

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

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

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

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

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

const zokrates = await zokratesInitialize()

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

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

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

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

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

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

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

calculateMapHash

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

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

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

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

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

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

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

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

Виконайте програму розкопок.

        const proof = zokrates.generateProof(
            digCompiled.program,
            runResults.witness,
            proverKey)

        return proof
    }

Використайте generateProof (opens in a new tab) і поверніть доведення.

const solidityVerifier = `
        // Map size: ${width} x ${height}
        \n${zokrates.exportSolidityVerifier(verifierKey)}
        `

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

    return {
        zkDig,
        calculateMapHash,
        solidityVerifier,
    }
}

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

Тестування безпеки

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

Дозволи

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

cause: {
        code: 3,
        message: 'execution reverted: revert: Zero knowledge verification fail',
        data: '0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000
000000000000000000000000000000000000000000000000205a65726f206b6e6f776c6564676520766572696669636174696f6
e206661696c'
      },

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

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

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

proof.proof = {
  a: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
  b: [
    ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
    ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
  ],
  c: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
}

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

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

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

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

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

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

Для цього:

  1. Встановіть Zokrates (opens in a new tab).

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

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

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

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

Проєктні рішення

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

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

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

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

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

Чому Zokrates?

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

Для вашого застосунку з іншими вимогами ви можете віддати перевагу Circum (opens in a new tab) або Cairo (opens 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 tau (opens in a new tab), у якій брали участь десятки учасників. Ймовірно, це достатньо безпечно і набагато простіше. Ми також не додаємо ентропію під час створення ключів, що полегшує користувачам перевірку конфігурації нульового розголошення.

Де проводити перевірку

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

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

Сплощувати мапу в TypeScript чи Zokrates?

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

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

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

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

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

Де зберігати мапи

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

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

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

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

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

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

Більше моїх робіт можна знайти тут (opens in a new tab).

Подяки

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

За будь-які помилки, що залишилися, відповідаю я.