Использование нулевого разглашения для секретного состояния
В блокчейне нет секретов. Все, что публикуется в блокчейне, открыто для чтения каждому. Это необходимо, поскольку блокчейн основан на том, что любой может его проверить. Однако игры часто полагаются на секретное состояние. Например, игра «Сапер» (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 |
Пример игры «Сапер»
«Сапер» (opens in a new tab) — это игра, в которой есть секретная карта с минным полем. Игрок выбирает определенное место, чтобы раскопать его. Если в этом месте находится мина, игра заканчивается. В противном случае игрок получает количество мин в восьми клетках, окружающих это место.
Это приложение написано с использованием MUD (opens in a new tab) — фреймворка, который позволяет нам хранить данные ончейн с помощью базы данных «ключ-значение» (opens in a new tab) и автоматически синхронизировать эти данные с офчейн-компонентами. Помимо синхронизации, MUD упрощает обеспечение контроля доступа, а также позволяет другим пользователям расширять (opens in a new tab) наше приложение без разрешений.
Запуск примера игры «Сапер»
Чтобы запустить пример игры «Сапер»:
-
Убедитесь, что у вас установлены необходимые компоненты (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). -
Клонируйте репозиторий.
git clone https://github.com/qbzzt/20240901-secret-state.git -
Установите пакеты.
cd 20240901-secret-state/ pnpm install npm install -g mprocsЕсли Foundry был установлен как часть
pnpm install, вам необходимо перезапустить оболочку командной строки. -
Скомпилируйте контракты
cd packages/contracts forge build cd ../.. -
Запустите программу (включая блокчейн anvil (opens in a new tab)) и подождите.
mprocsОбратите внимание, что запуск занимает много времени. Чтобы увидеть прогресс, сначала используйте стрелку вниз для прокрутки до вкладки contracts, чтобы увидеть, как развертываются контракты MUD. Когда вы получите сообщение Waiting for file changes…, контракты развернуты, и дальнейший прогресс будет происходить на вкладке server. Там вы ждете, пока не получите сообщение Verifier address: 0x.....
Если этот шаг выполнен успешно, вы увидите экран
mprocsс различными процессами слева и выводом консоли для текущего выбранного процесса справа.Если возникла проблема с
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
-
-
Теперь вы можете перейти к клиенту (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, происходят следующие шаги:
-
mprocs(opens in a new tab) запускает четыре компонента:- Anvil (opens in a new tab), который запускает локальный блокчейн
- Contracts (opens in a new tab), который компилирует (при необходимости) и развертывает контракты для MUD
- Client (opens in a new tab), который запускает Vite (opens in a new tab) для обслуживания пользовательского интерфейса и клиентского кода для веб-браузеров.
- Server (opens in a new tab), который выполняет действия сервера
-
Пакет
contractsразвертывает контракты MUD, а затем запускает скриптPostDeploy.s.sol(opens in a new tab). Этот скрипт задает конфигурацию. Код из GitHub указывает минное поле 10x5 с восемью минами на нем (opens in a new tab). -
Сервер (opens in a new tab) начинает с настройки MUD (opens in a new tab). Помимо прочего, это активирует синхронизацию данных, так что копия соответствующих таблиц существует в памяти сервера.
-
Сервер подписывает функцию для выполнения при изменении таблицы
Configuration(opens in a new tab). Эта функция (opens in a new tab) вызывается после того, какPostDeploy.s.solвыполняется и изменяет таблицу. -
Когда функция инициализации сервера получает конфигурацию, она вызывает
zkFunctions(opens in a new tab) для инициализации части сервера, отвечающей за нулевое разглашение. Это не может произойти до получения конфигурации, поскольку функции с нулевым разглашением должны иметь ширину и высоту минного поля в качестве констант. -
После инициализации части сервера с нулевым разглашением следующим шагом является развертывание контракта верификации с нулевым разглашением в блокчейне (opens in a new tab) и установка адреса верификатора в MUD.
-
Наконец, мы подписываемся на обновления, чтобы видеть, когда игрок запрашивает начало новой игры (opens in a new tab) или раскопку в существующей игре (opens in a new tab).
Новая игра
Вот что происходит, когда игрок запрашивает новую игру.
-
Если для этого игрока нет текущей игры, или она есть, но с gameId равным нулю, клиент отображает кнопку новой игры (opens in a new tab). Когда пользователь нажимает эту кнопку, React запускает функцию
newGame(opens in a new tab). -
newGame(opens in a new tab) — это вызовSystem. В MUD все вызовы маршрутизируются через контрактWorld, и в большинстве случаев вы вызываете<namespace>__<function name>. В данном случае вызов идет кapp__newGame, который MUD затем маршрутизирует кnewGameвGameSystem(opens in a new tab). -
Ончейн-функция проверяет, что у игрока нет текущей игры, и если ее нет, добавляет запрос в таблицу
PendingGame(opens in a new tab). -
Сервер обнаруживает изменение в
PendingGameи запускает подписанную функцию (opens in a new tab). Эта функция вызываетnewGame(opens in a new tab), которая, в свою очередь, вызываетcreateGame(opens in a new tab). -
Первое, что делает
createGame, — это создает случайную карту с соответствующим количеством мин (opens in a new tab). Затем она вызываетmakeMapBorders(opens in a new tab) для создания карты с пустыми границами, что необходимо для Zokrates. Наконец,createGameвызываетcalculateMapHash, чтобы получить хеш карты, который используется в качестве идентификатора игры. -
Функция
newGameдобавляет новую игру вgamesInProgress. -
Последнее, что делает сервер, — вызывает
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только одному адресу. Это ограничивает доступ к функциям сервера одним адресом, поэтому никто не может выдать себя за сервер. -
Ончейн-компонент обновляет соответствующие таблицы:
- Создает игру в
PlayerGame. - Устанавливает обратное сопоставление в
GamePlayer. - Удаляет запрос из
PendingGame.
- Создает игру в
-
Сервер идентифицирует изменение в
PendingGame, но ничего не делает, потому чтоwantsGame(opens in a new tab) имеет значение false. -
На клиенте
gameRecord(opens in a new tab) устанавливается на записьPlayerGameдля адреса игрока. Когда изменяетсяPlayerGame, изменяется иgameRecord. -
Если в
gameRecordесть значение, и игра не была выиграна или проиграна, клиент отображает карту (opens in a new tab).
Раскопка
-
Игрок нажимает кнопку ячейки карты (opens in a new tab), что вызывает функцию
dig(opens in a new tab). Эта функция вызываетdigончейн (opens in a new tab). -
Ончейн-компонент выполняет ряд проверок на корректность (opens in a new tab) и в случае успеха добавляет запрос на раскопку в
PendingDig(opens in a new tab). -
Сервер обнаруживает изменение в
PendingDig(opens in a new tab). Если оно действительно (opens in a new tab), он вызывает код с нулевым разглашением (opens in a new tab) (объясняется ниже) для генерации как результата, так и доказательства его действительности. -
Сервер (opens in a new tab) вызывает
digResponse(opens in a new tab) ончейн. -
digResponseделает две вещи. Сначала он проверяет доказательство с нулевым разглашением (opens in a new tab). Затем, если доказательство проходит проверку, он вызываетprocessDigResult(opens in a new tab) для фактической обработки результата. -
processDigResultпроверяет, была ли игра проиграна (opens in a new tab) или выиграна (opens in a new tab), и обновляетMap— ончейн-карту (opens in a new tab). -
Клиент автоматически подхватывает обновления и обновляет карту, отображаемую игроку (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"
Импортируйте привязки Zokrates для JavaScript (opens in a new tab). Нам нужна только функция initialize (opens in a new tab), потому что она возвращает промис, который разрешается во все определения Zokrates.
export const zkFunctions = async (width: number, height: number) : Promise<any> => {
Подобно самому Zokrates, мы также экспортируем только одну функцию, которая также является асинхронной (opens in a new tab). Когда она в конечном итоге возвращает результат, она предоставляет несколько функций, как мы увидим ниже.
const zokrates = await zokratesInitialize()
Инициализируем Zokrates, получаем все необходимое из библиотеки.
const hashFragment = `
import "utils/pack/bool/pack128.zok" as pack128;
import "hashes/poseidon/poseidon.zok" as poseidon;
.
.
.
}
`
const hashProgram = `
${hashFragment}
.
.
.
`
const digProgram = `
${hashFragment}
.
.
.
`
Далее у нас есть хеш-функция и две программы 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).
-
На компьютере, где запущен
anvil(блокчейн), задайте эти переменные среды.WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d -
Используйте
cast, чтобы попытаться установить адрес верификатора с неавторизованного адреса.cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $UNAUTHORIZED_KEYМало того, что
castсообщает об ошибке, вы также можете открыть MUD Dev Tools в игре в браузере, нажать Tables и выбрать app__VerifierAddress. Убедитесь, что адрес не равен нулю. -
Установите адрес верификатора как адрес сервера.
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, а хотя бы некоторые пользователи компилировали их самостоятельно с соответствующим ключом верификации.
Для этого:
-
Создайте файл
dig.zokс программой Zokrates. Приведенный ниже код предполагает, что вы сохранили исходный размер карты, 10x5.import "utils/pack/bool/pack128.zok" as pack128; import "hashes/poseidon/poseidon.zok" as poseidon; def hashMap(bool[12][7] map) -> field { bool[512] mut map1d = [false; 512]; u32 mut counter = 0; for u32 x in 0..12 { for u32 y in 0..7 { map1d[counter] = map[x][y]; counter = counter+1; } } field[4] hashMe = [ pack128(map1d[0..128]), pack128(map1d[128..256]), pack128(map1d[256..384]), pack128(map1d[384..512]) ]; return poseidon(hashMe); } // Количество мин в локации (x,y) def map2mineCount(bool[12][7] map, u32 x, u32 y) -> u8 { return if map[x+1][y+1] { 1 } else { 0 }; } def main(private bool[12][7] map, u32 x, u32 y) -> (field, u8) { return (hashMap(map) , if map2mineCount(map, x, y) > 0 { 0xFF } else { 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 и создайте ключ верификации. Ключ верификации должен быть создан с той же энтропией, которая использовалась на исходном сервере, в данном случае это пустая строка (opens in a new tab).
zokrates compile --input dig.zok zokrates setup -e "" -
Создайте верификатор 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) — это еще одно чистое вычисление, которое не нужно выполнять более одного раза для заданного размера минного поля. Опять же, ради простоты это делается только один раз.
Кроме того, мы могли бы использовать церемонию установки (setup ceremony) (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.
Ответственность за любые оставшиеся ошибки лежит на мне.
