Напишите специфичное для приложения plasma, которое сохраняет конфиденциальность
Введение
В отличие от роллапов, плазмы используют основную сеть Ethereum для целостности, но не для доступности. В этой статье мы напишем приложение, которое ведет себя как плазма, где Ethereum гарантирует целостность (отсутствие несанкционированных изменений), но не доступность (централизованный компонент может выйти из строя и отключить всю систему).
Приложение, которое мы здесь пишем, — это банк, сохраняющий конфиденциальность. Разные адреса имеют аккаунты с балансами, и они могут отправлять деньги (ETH) на другие аккаунты. Банк публикует хэши состояния (аккаунты и их балансы) и транзакции, но хранит фактические балансы оффчейн, где они могут оставаться приватными.
Проектирование
Это не готовая к эксплуатации система, а учебный инструмент. Поэтому она написана с несколькими упрощающими допущениями.
-
Фиксированный пул аккаунтов. Существует определенное количество аккаунтов, и каждый аккаунт принадлежит предопределенному адресу. Это значительно упрощает систему, поскольку в доказательствах с нулевым разглашением сложно обрабатывать структуры данных переменного размера. Для системы, готовой к производству, мы можем использовать корень Меркла в качестве хэша состояния и предоставлять доказательства Меркла для требуемых балансов.
-
Хранение в памяти. В производственной системе нам необходимо записывать все балансы аккаунтов на диск, чтобы сохранить их в случае перезапуска. Здесь же допустимо, если информация просто будет утеряна.
-
Только переводы. Производственная система потребовала бы способа вносить активы в банк и выводить их. Но здесь цель — просто проиллюстрировать концепцию, поэтому этот банк ограничен переводами.
Доказательства с нулевым разглашением
На фундаментальном уровне доказательство с нулевым разглашением показывает, что доказывающий знает некоторые данные, Dataprivate, такие, что существует связь Relationship между некоторыми публичными данными, Datapublic, и Dataprivate. Верификатор знает Relationship и Datapublic.
Чтобы сохранить конфиденциальность, нам необходимо, чтобы состояния и транзакции были приватными. Но для обеспечения целостности нам необходимо, чтобы криптографический хэш (opens in a new tab) состояний был публичным. Чтобы доказать людям, которые отправляют транзакции, что эти транзакции действительно произошли, нам также нужно публиковать хэши транзакций.
В большинстве случаев Dataprivate — это входные данные для программы доказательства с нулевым разглашением, а Datapublic — выходные.
Эти поля в Dataprivate:
- Staten, старое состояние
- Staten+1, новое состояние
- Транзакция, транзакция, которая изменяет старое состояние на новое. Эта транзакция должна включать следующие поля:
- Адрес назначения, который получает перевод
- Сумма перевода
- Nonce для гарантии того, что каждая транзакция может быть обработана только один раз. Адрес источника не обязательно должен быть в транзакции, поскольку его можно восстановить из подписи.
- Подпись, подпись, которая авторизована для выполнения транзакции. В нашем случае единственный адрес, авторизованный для выполнения транзакции, — это адрес источника. Поскольку наша система с нулевым разглашением работает именно так, нам также нужен публичный ключ аккаунта в дополнение к подписи Ethereum.
Это поля в Datapublic:
- Hash(Staten) — хэш старого состояния
- Hash(Staten+1) — хэш нового состояния
- Hash(Transaction) — хэш транзакции, которая изменяет состояние со Staten на Staten+1.
Связь проверяет несколько условий:
- Публичные хэши действительно являются правильными хэшами для приватных полей.
- Транзакция при применении к старому состоянию приводит к новому состоянию.
- Подпись исходит от адреса источника транзакции.
Благодаря свойствам криптографических хэш-функций, доказательство этих условий достаточно для обеспечения целостности.
Структуры данных
Основная структура данных — это состояние, хранимое сервером. Для каждого аккаунта сервер отслеживает баланс аккаунта и nonce (opens in a new tab), используемый для предотвращения атак повторного воспроизведения (opens in a new tab).
Компоненты
Эта система требует двух компонентов:
- Сервер, который получает транзакции, обрабатывает их и публикует хэши в блокчейн вместе с доказательствами с нулевым разглашением.
- Смарт-контракт, который хранит хэши и проверяет доказательства с нулевым разглашением, чтобы убедиться в легитимности переходов состояний.
Потоки данных и управления
Это способы, которыми различные компоненты взаимодействуют для перевода с одного аккаунта на другой.
-
Веб-браузер отправляет подписанную транзакцию, запрашивая перевод со счета подписывающего на другой счет.
-
Сервер проверяет, что транзакция действительна:
- У подписывающего есть счет в банке с достаточным балансом.
- У получателя есть счет в банке.
-
Сервер вычисляет новое состояние, вычитая переведенную сумму из баланса подписывающего и добавляя ее к балансу получателя.
-
Сервер вычисляет доказательство с нулевым разглашением того, что изменение состояния является действительным.
-
Сервер отправляет в Ethereum транзакцию, которая включает:
- Хэш нового состояния
- Хэш транзакции (чтобы отправитель транзакции мог знать, что она обработана)
- Доказательство с нулевым разглашением, которое доказывает, что переход к новому состоянию действителен
-
Смарт-контракт проверяет доказательство с нулевым разглашением.
-
Если доказательство с нулевым разглашением проходит проверку, смарт-контракт выполняет следующие действия:
- Обновляет текущий хэш состояния на новый хэш состояния
- Генерирует запись в журнале с новым хэшем состояния и хэшем транзакции
Инструменты
Для клиентского кода мы будем использовать Vite (opens in a new tab), React (opens in a new tab), Viem (opens in a new tab) и Wagmi (opens in a new tab). Это стандартные отраслевые инструменты; если вы с ними не знакомы, можете воспользоваться этим руководством.
Большая часть сервера написана на JavaScript с использованием Node (opens in a new tab). Часть с нулевым разглашением написана на Noir (opens in a new tab). Нам нужна версия 1.0.0-beta.10, поэтому после того, как вы установите Noir согласно инструкции (opens in a new tab), выполните:
1noirup -v 1.0.0-beta.10Блокчейн, который мы используем, — это anvil, локальный тестовый блокчейн, который является частью Foundry (opens in a new tab).
Реализация
Поскольку это сложная система, мы будем реализовывать ее поэтапно.
Этап 1 — Ручное нулевое разглашение
На первом этапе мы подпишем транзакцию в браузере, а затем вручную предоставим информацию для доказательства с нулевым разглашением. Код с нулевым разглашением ожидает получить эту информацию в server/noir/Prover.toml (документация здесь (opens in a new tab)).
Чтобы увидеть это в действии:
-
Убедитесь, что у вас установлены Node (opens in a new tab) и Noir (opens in a new tab). Желательно установить их в UNIX-системе, такой как macOS, Linux или WSL (opens in a new tab).
-
Загрузите код этапа 1 и запустите веб-сервер для обслуживания клиентского кода.
1git clone https://github.com/qbzzt/250911-zk-bank.git -b 01-manual-zk2cd 250911-zk-bank3cd client4npm install5npm run devПричина, по которой здесь нужен веб-сервер, заключается в том, что для предотвращения некоторых видов мошенничества многие кошельки (например, MetaMask) не принимают файлы, обслуживаемые непосредственно с диска.
-
Откройте браузер с кошельком.
-
В кошельке введите новую кодовую фразу. Обратите внимание, что это удалит вашу существующую кодовую фразу, поэтому убедитесь, что у вас есть резервная копия.
Кодовая фраза —
test test test test test test test test test test test junk, стандартная тестовая кодовая фраза для anvil. -
Перейдите к клиентскому коду (opens in a new tab).
-
Подключитесь к кошельку и выберите аккаунт назначения и сумму.
-
Нажмите Sign (Подписать) и подпишите транзакцию.
-
Под заголовком Prover.toml вы найдете текст. Замените
server/noir/Prover.tomlэтим текстом. -
Выполните доказательство с нулевым разглашением.
1cd ../server/noir2nargo executeВывод должен быть похож на
1ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute23[zkBank] Circuit witness successfully solved4[zkBank] Witness saved to target/zkBank.gz5[zkBank] Circuit output: (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85) -
Сравните последние два значения с хэшем, который вы видите в веб-браузере, чтобы проверить, правильно ли хэшировано сообщение.
server/noir/Prover.toml
Этот файл (opens in a new tab) показывает формат информации, ожидаемый Noir.
1message="send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 500 finney (milliEth) 0 "Сообщение представлено в текстовом формате, что облегчает его понимание пользователем (что необходимо при подписи) и разбор кодом Noir. Сумма указана в finney, чтобы, с одной стороны, обеспечить возможность дробных переводов, а с другой — быть легко читаемой. Последнее число — это nonce (opens in a new tab).
Строка имеет длину 100 символов. Доказательства с нулевым разглашением плохо работают с данными переменного размера, поэтому часто необходимо дополнять данные.
1pubKeyX=["0x83",...,"0x75"]2pubKeyY=["0x35",...,"0xa5"]3signature=["0xb1",...,"0x0d"]Эти три параметра являются массивами байтов фиксированного размера.
1[[accounts]]2address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"3balance=100_0004nonce=056[[accounts]]7address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"8balance=100_0009nonce=0Показать всеЭто способ задания массива структур. Для каждой записи мы указываем адрес, баланс (в milliETH, т. е. finney (opens in a new tab)) и следующее значение nonce.
client/src/Transfer.tsx
Этот файл (opens in a new tab) реализует обработку на стороне клиента и генерирует файл server/noir/Prover.toml (тот, который включает параметры с нулевым разглашением).
Вот объяснение наиболее интересных частей.
1export default attrs => {Эта функция создает компонент Transfer React, который могут импортировать другие файлы.
1 const accounts = [2 "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",3 "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",4 "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",5 "0x90F79bf6EB2c4f870365E785982E1f101E93b906",6 "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",7 ]Это адреса аккаунтов, адреса, созданные test ... кодовой фразой test junk. Если вы хотите использовать свои собственные адреса, просто измените это определение.
1 const account = useAccount()2 const wallet = createWalletClient({3 transport: custom(window.ethereum!)4 })Эти хуки Wagmi (opens in a new tab) позволяют нам получить доступ к библиотеке viem (opens in a new tab) и кошельку.
1 const message = `send ${toAccount} ${ethAmount*1000} finney (milliEth) ${nonce}`.padEnd(100, " ")Это сообщение, дополненное пробелами. Каждый раз, когда изменяется одна из переменных useState (opens in a new tab), компонент перерисовывается, а message обновляется.
1 const sign = async () => {Эта функция вызывается, когда пользователь нажимает кнопку Sign (Подписать). Сообщение обновляется автоматически, но подпись требует подтверждения пользователя в кошельке, и мы не хотим запрашивать его без необходимости.
1 const signature = await wallet.signMessage({2 account: fromAccount,3 message,4 })Попросить кошелек подписать сообщение (opens in a new tab).
1 const hash = hashMessage(message)Получить хэш сообщения. Полезно предоставить его пользователю для отладки (кода Noir).
1 const pubKey = await recoverPublicKey({2 hash,3 signature4 })Получить публичный ключ (opens in a new tab). Это необходимо для функции Noir ecrecover (opens in a new tab).
1 setSignature(signature)2 setHash(hash)3 setPubKey(pubKey)Установить переменные состояния. Это действие перерисовывает компонент (после завершения функции sign) и показывает пользователю обновленные значения.
1 let proverToml = `Текст для Prover.toml.
1message="${message}"23pubKeyX=${hexToArray(pubKey.slice(4,4+2*32))}4pubKeyY=${hexToArray(pubKey.slice(4+2*32))}Viem предоставляет нам публичный ключ в виде 65-байтовой шестнадцатеричной строки. Первый байт — 0x04, маркер версии. За ним следуют 32 байта для x публичного ключа, а затем 32 байта для y публичного ключа.
Однако Noir ожидает получить эту информацию в виде двух массивов байтов, один для x и один для y. Проще разобрать это здесь, на клиенте, а не в рамках доказательства с нулевым разглашением.
Обратите внимание, что это хорошая практика в области доказательств с нулевым разглашением в целом. Код внутри доказательства с нулевым разглашением является дорогостоящим, поэтому любая обработка, которая может быть выполнена вне доказательства с нулевым разглашением, должна быть выполнена вне доказательства с нулевым разглашением.
1signature=${hexToArray(signature.slice(2,-2))}Подпись также предоставляется в виде 65-байтовой шестнадцатеричной строки. Однако последний байт необходим только для восстановления публичного ключа. Поскольку публичный ключ уже будет предоставлен коду Noir, он нам не нужен для проверки подписи, и код Noir его не требует.
1${accounts.map(accountInProverToml).reduce((a,b) => a+b, "")}2`Предоставьте аккаунты.
1 setProverToml(proverToml)2 }34 return (5 <>6 <h2>Перевод</h2>Это формат HTML (точнее, JSX (opens in a new tab)) компонента.
server/noir/src/main.nr
Этот файл (opens in a new tab) является фактическим кодом с нулевым разглашением.
1use std::hash::pedersen_hash;Хэш Педерсена (opens in a new tab) предоставляется стандартной библиотекой Noir (opens in a new tab). Доказательства с нулевым разглашением обычно используют эту хэш-функцию. Ее гораздо проще вычислять внутри арифметических схем (opens in a new tab) по сравнению со стандартными хэш-функциями.
1use keccak256::keccak256;2use dep::ecrecover;Эти две функции являются внешними библиотеками, определенными в Nargo.toml (opens in a new tab). Они представляют собой именно то, для чего они названы: функция, которая вычисляет хэш keccak256 (opens in a new tab), и функция, которая проверяет подписи Ethereum и восстанавливает адрес Ethereum подписывающего.
1global ACCOUNT_NUMBER : u32 = 5;Noir вдохновлен Rust (opens in a new tab). Переменные по умолчанию являются константами. Так мы определяем глобальные константы конфигурации. В частности, ACCOUNT_NUMBER — это количество аккаунтов, которые мы храним.
Типы данных с именем u<number> — это беззнаковые числа с указанным количеством битов. Единственными поддерживаемыми типами являются u8, u16, u32, u64 и u128.
1global FLAT_ACCOUNT_FIELDS : u32 = 2;Эта переменная используется для хэша Педерсена аккаунтов, как объяснено ниже.
1global MESSAGE_LENGTH : u32 = 100;Как объяснялось выше, длина сообщения фиксирована. Она указана здесь.
1global ASCII_MESSAGE_LENGTH : [u8; 3] = [0x31, 0x30, 0x30];2global HASH_BUFFER_SIZE : u32 = 26+3+MESSAGE_LENGTH;Подписи EIP-191 (opens in a new tab) требуют буфер с 26-байтовым префиксом, за которым следует длина сообщения в ASCII и, наконец, само сообщение.
1struct Account {2 balance: u128,3 address: Field,4 nonce: u32,5}Информация, которую мы храним об аккаунте. Field (opens in a new tab) — это число, обычно до 253 бит, которое можно использовать непосредственно в арифметической схеме (opens in a new tab), реализующей доказательство с нулевым разглашением. Здесь мы используем Field для хранения 160-битного адреса Ethereum.
1struct TransferTxn {2 from: Field,3 to: Field,4 amount: u128,5 nonce: u326}Информация, которую мы храним для транзакции перевода.
1fn flatten_account(account: Account) -> [Field; FLAT_ACCOUNT_FIELDS] {Определение функции. Параметром является информация об аккаунте. Результатом является массив переменных Field, длина которого равна FLAT_ACCOUNT_FIELDS.
1 let flat = [2 account.address,3 ((account.balance << 32) + account.nonce.into()).into(),4 ];Первое значение в массиве — это адрес аккаунта. Второе включает в себя как баланс, так и nonce. Вызовы .into() изменяют число на тот тип данных, которым оно должно быть. account.nonce — это значение u32, но чтобы добавить его к account.balance << 32, значению u128, оно должно быть u128. Это первый .into(). Второй преобразует результат u128 в Field, чтобы он поместился в массив.
1 flat2}В Noir функции могут возвращать значение только в конце (нет раннего возврата). Чтобы указать возвращаемое значение, вы вычисляете его непосредственно перед закрывающей скобкой функции.
1fn flatten_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] {Эта функция преобразует массив аккаунтов в массив Field, который можно использовать в качестве входных данных для хэша Петерсена.
1 let mut flat: [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] = [0; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER];Так вы указываете изменяемую переменную, то есть не константу. Переменные в Noir всегда должны иметь значение, поэтому мы инициализируем эту переменную всеми нулями.
1 for i in 0..ACCOUNT_NUMBER {Это цикл for. Обратите внимание, что границы являются константами. В Noir границы циклов должны быть известны во время компиляции. Причина в том, что арифметические схемы не поддерживают управление потоком. При обработке цикла for компилятор просто помещает код внутри него несколько раз, по одному на каждую итерацию.
1 let fields = flatten_account(accounts[i]);2 for j in 0..FLAT_ACCOUNT_FIELDS {3 flat[i*FLAT_ACCOUNT_FIELDS + j] = fields[j];4 }5 }67 flat8}910fn hash_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> Field {11 pedersen_hash(flatten_accounts(accounts))12}Показать всеНаконец, мы дошли до функции, которая хэширует массив аккаунтов.
1fn find_account(accounts: [Account; ACCOUNT_NUMBER], address: Field) -> u32 {2 let mut account : u32 = ACCOUNT_NUMBER;34 for i in 0..ACCOUNT_NUMBER {5 if accounts[i].address == address {6 account = i;7 }8 }Эта функция находит аккаунт с определенным адресом. Эта функция была бы ужасно неэффективной в стандартном коде, потому что она перебирает все аккаунты, даже после того, как нашла адрес.
Однако в доказательствах с нулевым разглашением нет управления потоком. Если нам когда-либо понадобится проверить условие, мы должны проверять его каждый раз.
Аналогичная вещь происходит с операторами if. Оператор if в приведенном выше цикле преобразуется в эти математические утверждения.
conditionresult = accounts[i].address == address // единица, если они равны, ноль в противном случае
accountnew = conditionresult*i + (1-conditionresult)*accountold
1 assert (account < ACCOUNT_NUMBER, f"{address} does not have an account");23 account4}Функция assert (opens in a new tab) вызывает сбой доказательства с нулевым разглашением, если утверждение ложно. В данном случае, если мы не можем найти аккаунт с соответствующим адресом. Чтобы сообщить адрес, мы используем форматированную строку (opens in a new tab).
1fn apply_transfer_txn(accounts: [Account; ACCOUNT_NUMBER], txn: TransferTxn) -> [Account; ACCOUNT_NUMBER] {Эта функция применяет транзакцию перевода и возвращает новый массив аккаунтов.
1 let from = find_account(accounts, txn.from);2 let to = find_account(accounts, txn.to);34 let (txnFrom, txnAmount, txnNonce, accountNonce) =5 (txn.from, txn.amount, txn.nonce, accounts[from].nonce);Мы не можем получить доступ к элементам структуры внутри форматированной строки в Noir, поэтому мы создаем пригодную для использования копию.
1 assert (accounts[from].balance >= txn.amount,2 f"{txnFrom} does not have {txnAmount} finney");34 assert (accounts[from].nonce == txn.nonce,5 f"Transaction has nonce {txnNonce}, but the account is expected to use {accountNonce}");Это два условия, которые могут сделать транзакцию недействительной.
1 let mut newAccounts = accounts;23 newAccounts[from].balance -= txn.amount;4 newAccounts[from].nonce += 1;5 newAccounts[to].balance += txn.amount;67 newAccounts8}Создайте новый массив аккаунтов, а затем верните его.
1fn readAddress(messageBytes: [u8; MESSAGE_LENGTH]) -> FieldЭта функция считывает адрес из сообщения.
1{2 let mut result : Field = 0;34 for i in 7..47 {Адрес всегда имеет длину 20 байт (т. е. 40 шестнадцатеричных цифр) и начинается с символа №7.
1 result *= 0x10;2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-93 result += (messageBytes[i]-48).into();4 }5 if messageBytes[i] >= 65 & messageBytes[i] <= 70 { // A-F6 result += (messageBytes[i]-65+10).into()7 }8 if messageBytes[i] >= 97 & messageBytes[i] <= 102 { // a-f9 result += (messageBytes[i]-97+10).into()10 } 11 } 1213 result14}1516fn readAmountAndNonce(messageBytes: [u8; MESSAGE_LENGTH]) -> (u128, u32)Показать всеСчитайте сумму и nonce из сообщения.
1{2 let mut amount : u128 = 0;3 let mut nonce: u32 = 0;4 let mut stillReadingAmount: bool = true;5 let mut lookingForNonce: bool = false;6 let mut stillReadingNonce: bool = false;В сообщении первое число после адреса — это сумма в finney (т. е. тысячная доля ETH) для перевода. Второе число — это nonce. Любой текст между ними игнорируется.
1 for i in 48..MESSAGE_LENGTH {2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-93 let digit = (messageBytes[i]-48);45 if stillReadingAmount {6 amount = amount*10 + digit.into();7 }89 if lookingForNonce { // We just found it10 stillReadingNonce = true;11 lookingForNonce = false;12 }1314 if stillReadingNonce {15 nonce = nonce*10 + digit.into();16 }17 } else {18 if stillReadingAmount {19 stillReadingAmount = false;20 lookingForNonce = true;21 }22 if stillReadingNonce {23 stillReadingNonce = false;24 }25 }26 }2728 (amount, nonce)29}Показать всеВозврат кортежа (opens in a new tab) — это способ в Noir вернуть несколько значений из функции.
1fn readTransferTxn(message: str<MESSAGE_LENGTH>) -> TransferTxn 2{3 let mut txn: TransferTxn = TransferTxn { from: 0, to: 0, amount:0, nonce:0 };4 let messageBytes = message.as_bytes();56 txn.to = readAddress(messageBytes);7 let (amount, nonce) = readAmountAndNonce(messageBytes);8 txn.amount = amount;9 txn.nonce = nonce;1011 txn12}Показать всеЭта функция преобразует сообщение в байты, а затем преобразует суммы в TransferTxn.
1// The equivalent to Viem's hashMessage2// https://viem.sh/docs/utilities/hashMessage#hashmessage3fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {Мы смогли использовать хэш Педерсена для аккаунтов, потому что они хэшируются только внутри доказательства с нулевым разглашением. Однако в этом коде нам нужно проверить подпись сообщения, которая генерируется браузером. Для этого нам нужно следовать формату подписи Ethereum в EIP-191 (opens in a new tab). Это означает, что нам нужно создать объединенный буфер со стандартным префиксом, длиной сообщения в ASCII и самим сообщением, и использовать стандартный для Ethereum keccak256 для его хэширования.
1 // ASCII prefix2 let prefix_bytes = [3 0x19, // \x194 0x45, // 'E'5 0x74, // 't'6 0x68, // 'h'7 0x65, // 'e'8 0x72, // 'r'9 0x65, // 'e'10 0x75, // 'u'11 0x6D, // 'm'12 0x20, // ' '13 0x53, // 'S'14 0x69, // 'i'15 0x67, // 'g'16 0x6E, // 'n'17 0x65, // 'e'18 0x64, // 'd'19 0x20, // ' '20 0x4D, // 'M'21 0x65, // 'e'22 0x73, // 's'23 0x73, // 's'24 0x61, // 'a'25 0x67, // 'g'26 0x65, // 'e'27 0x3A, // ':'28 0x0A // '\n'29 ];Показать всеЧтобы избежать случаев, когда приложение просит пользователя подписать сообщение, которое может быть использовано как транзакция или для какой-либо другой цели, EIP-191 указывает, что все подписанные сообщения начинаются с символа 0x19 (недействительный символ ASCII), за которым следует Ethereum Signed Message: и новая строка.
1 let mut buffer: [u8; HASH_BUFFER_SIZE] = [0u8; HASH_BUFFER_SIZE];2 for i in 0..26 {3 buffer[i] = prefix_bytes[i];4 }56 let messageBytes : [u8; MESSAGE_LENGTH] = message.as_bytes();78 if MESSAGE_LENGTH <= 9 {9 for i in 0..1 {10 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];11 }1213 for i in 0..MESSAGE_LENGTH {14 buffer[i+26+1] = messageBytes[i];15 }16 }1718 if MESSAGE_LENGTH >= 10 & MESSAGE_LENGTH <= 99 {19 for i in 0..2 {20 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];21 }2223 for i in 0..MESSAGE_LENGTH {24 buffer[i+26+2] = messageBytes[i];25 }26 }2728 if MESSAGE_LENGTH >= 100 {29 for i in 0..3 {30 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];31 }3233 for i in 0..MESSAGE_LENGTH {34 buffer[i+26+3] = messageBytes[i];35 }36 }3738 assert(MESSAGE_LENGTH < 1000, "Messages whose length is over three digits are not supported");Показать всеОбрабатывать длины сообщений до 999 и выдавать ошибку, если она больше. Я добавил этот код, хотя длина сообщения является константой, потому что это упрощает ее изменение. В производственной системе вы, вероятно, просто предположите, что MESSAGE_LENGTH не изменится ради лучшей производительности.
1 keccak256::keccak256(buffer, HASH_BUFFER_SIZE)2}Используйте стандартную функцию Ethereum keccak256.
1fn signatureToAddressAndHash(2 message: str<MESSAGE_LENGTH>, 3 pubKeyX: [u8; 32],4 pubKeyY: [u8; 32],5 signature: [u8; 64]6 ) -> (Field, Field, Field) // address, first 16 bytes of hash, last 16 bytes of hash 7{Эта функция проверяет подпись, что требует хэша сообщения. Затем она предоставляет нам адрес, который ее подписал, и хэш сообщения. Хэш сообщения предоставляется в виде двух значений Field, потому что их проще использовать в остальной части программы, чем массив байтов.
Нам нужно использовать два значения Field, потому что вычисления с полями выполняются по модулю (opens in a new tab) большого числа, но это число обычно меньше 256 бит (иначе было бы трудно выполнять эти вычисления в EVM).
1 let hash = hashMessage(message);23 let mut (hash1, hash2) = (0,0);45 for i in 0..16 {6 hash1 = hash1*256 + hash[31-i].into();7 hash2 = hash2*256 + hash[15-i].into();8 }Укажите hash1 и hash2 как изменяемые переменные и запишите в них хэш побайтово.
1 (2 ecrecover::ecrecover(pubKeyX, pubKeyY, signature, hash), Это похоже на ecrecover в Solidity (opens in a new tab), с двумя важными отличиями:
- Если подпись недействительна, вызов не проходит
assert, и программа прерывается. - Хотя публичный ключ можно восстановить из подписи и хэша, это обработка, которую можно выполнить извне, и, следовательно, не стоит делать внутри доказательства с нулевым разглашением. Если кто-то попытается обмануть нас здесь, проверка подписи не удастся.
1 hash1,2 hash23 )4}56fn main(7 accounts: [Account; ACCOUNT_NUMBER],8 message: str<MESSAGE_LENGTH>,9 pubKeyX: [u8; 32],10 pubKeyY: [u8; 32],11 signature: [u8; 64],12 ) -> pub (13 Field, // Hash of old accounts array14 Field, // Hash of new accounts array15 Field, // First 16 bytes of message hash16 Field, // Last 16 bytes of message hash17 )Показать всеНаконец, мы добрались до функции main. Нам нужно доказать, что у нас есть транзакция, которая действительным образом изменяет хэш аккаунтов со старого значения на новое. Нам также нужно доказать, что у нее есть этот конкретный хэш транзакции, чтобы человек, который ее отправил, знал, что его транзакция была обработана.
1{2 let mut txn = readTransferTxn(message);Нам нужно, чтобы txn был изменяемым, потому что мы не читаем адрес «от кого» из сообщения, мы читаем его из подписи.
1 let (fromAddress, txnHash1, txnHash2) = signatureToAddressAndHash(2 message,3 pubKeyX,4 pubKeyY,5 signature);67 txn.from = fromAddress;89 let newAccounts = apply_transfer_txn(accounts, txn);1011 (12 hash_accounts(accounts),13 hash_accounts(newAccounts),14 txnHash1,15 txnHash216 )17}Показать всеЭтап 2 — Добавление сервера
На втором этапе мы добавляем сервер, который получает и реализует транзакции перевода из браузера.
Чтобы увидеть это в действии:
-
Остановите Vite, если он запущен.
-
Загрузите ветку, которая включает сервер, и убедитесь, что у вас есть все необходимые модули.
1git checkout 02-add-server2cd client3npm install4cd ../server5npm installНет необходимости компилировать код Noir, он такой же, как код, который вы использовали для этапа 1.
-
Запустите сервер.
1npm run start -
В отдельном окне командной строки запустите Vite для обслуживания кода браузера.
1cd client2npm run dev -
Перейдите к клиентскому коду по адресу http://localhost:5173 (opens in a new tab).
-
Прежде чем вы сможете выполнить транзакцию, вам нужно знать nonce, а также сумму, которую вы можете отправить. Чтобы получить эту информацию, нажмите Update account data (Обновить данные аккаунта) и подпишите сообщение.
Здесь у нас дилемма. С одной стороны, мы не хотим подписывать сообщение, которое может быть использовано повторно (атака повторного воспроизведения (opens in a new tab)), и именно поэтому нам в первую очередь нужен nonce. Однако у нас еще нет nonce. Решение состоит в том, чтобы выбрать nonce, который можно использовать только один раз и который у нас уже есть с обеих сторон, например, текущее время.
Проблема с этим решением в том, что время может быть не идеально синхронизировано. Поэтому вместо этого мы подписываем значение, которое меняется каждую минуту. Это означает, что наше окно уязвимости для атак повторного воспроизведения составляет не более одной минуты. Учитывая, что в производственной среде подписанный запрос будет защищен TLS, и что другая сторона туннеля — сервер — уже может раскрыть баланс и nonce (он должен их знать для работы), это приемлемый риск.
-
Как только браузер получает обратно баланс и nonce, он показывает форму перевода. Выберите адрес назначения и сумму и нажмите Transfer (Перевести). Подпишите этот запрос.
-
Чтобы увидеть перевод, либо обновите данные аккаунта, либо посмотрите в окне, где вы запускаете сервер. Сервер регистрирует состояние каждый раз, когда оно меняется.
1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start23> server@1.0.0 start4> node --experimental-json-modules index.mjs56Listening on port 30007Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 processed8New state:90xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 64000 (1)100x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 100000 (0)110x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)120x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)130x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)14Txn send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 7200 finney (milliEth) 1 processed15New state:160xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 56800 (2)170x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)180x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)190x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)200x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)21Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 3000 finney (milliEth) 2 processed22New state:230xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 53800 (3)240x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)250x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)260x90F79bf6EB2c4f870365E785982E1f101E93b906 has 139000 (0)270x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)Показать все
server/index.mjs
Этот файл (opens in a new tab) содержит серверный процесс и взаимодействует с кодом Noir в main.nr (opens in a new tab). Вот объяснение интересных частей.
1import { Noir } from '@noir-lang/noir_js'Библиотека noir.js (opens in a new tab) взаимодействует между кодом JavaScript и кодом Noir.
1const circuit = JSON.parse(await fs.readFile("./noir/target/zkBank.json"))2const noir = new Noir(circuit)Загрузите арифметическую схему — скомпилированную программу Noir, которую мы создали на предыдущем этапе, — и подготовьтесь к ее выполнению.
1// Мы предоставляем информацию об аккаунте только в ответ на подписанный запрос2const accountInformation = async signature => {3 const fromAddress = await recoverAddress({4 hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),5 signature6 })Для предоставления информации об аккаунте нам нужна только подпись. Причина в том, что мы уже знаем, каким будет сообщение, и, следовательно, хэш сообщения.
1const processMessage = async (message, signature) => {Обработайте сообщение и выполните закодированную в нем транзакцию.
1 // Получить публичный ключ2 const pubKey = await recoverPublicKey({3 hash,4 signature5 })Теперь, когда мы запускаем JavaScript на сервере, мы можем получить публичный ключ там, а не на клиенте.
1 let noirResult2 try {3 noirResult = await noir.execute({4 message,5 signature: signature.slice(2,-2).match(/.{2}/g).map(x => `0x${x}`),6 pubKeyX,7 pubKeyY,8 accounts: Accounts9 })Показать всеnoir.execute запускает программу Noir. Параметры эквивалентны тем, что предоставлены в Prover.toml (opens in a new tab). Обратите внимание, что длинные значения предоставляются как массив шестнадцатеричных строк (["0x60", "0xA7"]), а не как одно шестнадцатеричное значение (0x60A7), как это делает Viem.
1 } catch (err) {2 console.log(`Noir error: ${err}`)3 throw Error("Invalid transaction, not processed")4 }Если возникла ошибка, перехватите ее, а затем передайте упрощенную версию клиенту.
1 Accounts[fromAccountNumber].nonce++2 Accounts[fromAccountNumber].balance -= amount3 Accounts[toAccountNumber].balance += amountПримените транзакцию. Мы уже сделали это в коде Noir, но здесь проще сделать это снова, чем извлекать результат оттуда.
1let Accounts = [2 {3 address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",4 balance: 5000,5 nonce: 0,6 },Начальная структура Accounts.
Этап 3 — Смарт-контракты Ethereum
-
Остановите процессы сервера и клиента.
-
Загрузите ветку со смарт-контрактами и убедитесь, что у вас есть все необходимые модули.
1git checkout 03-smart-contracts2cd client3npm install4cd ../server5npm install -
Запустите
anvilв отдельном окне командной строки. -
Сгенерируйте ключ верификации и верификатор Solidity, затем скопируйте код верификатора в проект Solidity.
1cd noir2bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak3bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol4cp target/Verifier.sol ../../smart-contracts/src -
Перейдите к смарт-контрактам и установите переменные среды для использования блокчейна
anvil.1cd ../../smart-contracts2export ETH_RPC_URL=http://localhost:85453ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -
Разверните
Verifier.solи сохраните адрес в переменной среды.1VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'`2echo $VERIFIER_ADDRESS -
Разверните контракт
ZkBank.1ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'`2echo $ZKBANK_ADDRESSЗначение
0x199..67b— это хэш Педерсена начального состоянияAccounts. Если вы измените это начальное состояние вserver/index.mjs, вы можете запустить транзакцию, чтобы увидеть начальный хэш, сообщаемый доказательством с нулевым разглашением. -
Запустите сервер.
1cd ../server2npm run start -
Запустите клиент в другом окне командной строки.
1cd client2npm run dev -
Выполните несколько транзакций.
-
Чтобы убедиться, что состояние изменилось в блокчейне, перезапустите процесс сервера. Убедитесь, что
ZkBankбольше не принимает транзакции, потому что исходное значение хэша в транзакциях отличается от значения хэша, хранящегося в блокчейне.Это ожидаемый тип ошибки.
1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start23> server@1.0.0 start4> node --experimental-json-modules index.mjs56Listening on port 30007Verification error: ContractFunctionExecutionError: The contract function "processTransaction" reverted with the following reason:8Wrong old state hash910Contract Call:11 address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F051212 function: processTransaction(bytes _proof, bytes32[] _publicInputs)13 args: (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000Показать все
server/index.mjs
Изменения в этом файле в основном касаются создания фактического доказательства и его отправки в блокчейн.
1import { exec } from 'child_process'2import util from 'util'34const execPromise = util.promisify(exec)Нам нужно использовать пакет Barretenberg (opens in a new tab) для создания фактического доказательства для отправки в блокчейн. Мы можем использовать этот пакет либо через интерфейс командной строки (bb), либо с помощью библиотеки JavaScript, bb.js (opens in a new tab). Библиотека JavaScript работает намного медленнее, чем нативный код, поэтому мы используем exec (opens in a new tab) для использования командной строки.
Обратите внимание, что если вы решите использовать bb.js, вам нужно будет использовать версию, совместимую с версией Noir, которую вы используете. На момент написания статьи текущая версия Noir (1.0.0-beta.11) использует bb.js версии 0.87.
1const zkBankAddress = process.env.ZKBANK_ADDRESS || "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"Адрес здесь — это тот, который вы получаете, начиная с чистого anvil и следуя приведенным выше инструкциям.
1const walletClient = createWalletClient({ 2 chain: anvil, 3 transport: http(), 4 account: privateKeyToAccount("0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6")5})Этот приватный ключ — один из стандартных предварительно пополненных аккаунтов в anvil.
1const generateProof = async (witness, fileID) => {Сгенерируйте доказательство с помощью исполняемого файла bb.
1 const fname = `witness-${fileID}.gz` 2 await fs.writeFile(fname, witness)Запишите свидетельство в файл.
1 await execPromise(`bb prove -b ./noir/target/zkBank.json -w ${fname} -o ${fileID} --oracle_hash keccak --output_format fields`)Фактически создайте доказательство. Этот шаг также создает файл с публичными переменными, но нам он не нужен. Мы уже получили эти переменные из noir.execute.
1 const proof = "0x" + JSON.parse(await fs.readFile(`./${fileID}/proof_fields.json`)).reduce((a,b) => a+b, "").replace(/0x/g, "")Доказательство представляет собой JSON-массив значений Field, каждое из которых представлено шестнадцатеричным значением. Однако нам нужно отправить его в транзакции как одно значение bytes, которое Viem представляет большой шестнадцатеричной строкой. Здесь мы изменяем формат, объединяя все значения, удаляя все 0x и затем добавляя один в конце.
1 await execPromise(`rm -r ${fname} ${fileID}`)23 return proof4}Очистка и возврат доказательства.
1const processMessage = async (message, signature) => {2 .3 .4 .56 const publicFields = noirResult.returnValue.map(x=>'0x' + x.slice(2).padStart(64, "0"))Публичные поля должны быть массивом 32-байтовых значений. Однако, поскольку нам нужно было разделить хэш транзакции на два значения Field, он отображается как 16-байтовое значение. Здесь мы добавляем нули, чтобы Viem понял, что это на самом деле 32 байта.
1 const proof = await generateProof(noirResult.witness, `${fromAddress}-${nonce}`)Каждый адрес использует каждый nonce только один раз, поэтому мы можем использовать комбинацию fromAddress и nonce в качестве уникального идентификатора для файла свидетельства и выходного каталога.
1 try {2 await zkBank.write.processTransaction([3 proof, publicFields])4 } catch (err) {5 console.log(`Verification error: ${err}`)6 throw Error("Can't verify the transaction onchain")7 }8 .9 .10 .11}Показать всеОтправьте транзакцию в блокчейн.
smart-contracts/src/ZkBank.sol
Это ончейн-код, который получает транзакцию.
1// SPDX-License-Identifier: MIT23pragma solidity >=0.8.21;45import {HonkVerifier} from "./Verifier.sol";67contract ZkBank {8 HonkVerifier immutable myVerifier;9 bytes32 currentStateHash;1011 constructor(address _verifierAddress, bytes32 _initialStateHash) {12 currentStateHash = _initialStateHash;13 myVerifier = HonkVerifier(_verifierAddress);14 }Показать всеОнчейн-код должен отслеживать две переменные: верификатор (отдельный контракт, созданный nargo) и текущий хэш состояния.
1 event TransactionProcessed(2 bytes32 indexed transactionHash,3 bytes32 oldStateHash,4 bytes32 newStateHash5 );Каждый раз, когда состояние изменяется, мы генерируем событие TransactionProcessed.
1 function processTransaction(2 bytes calldata _proof,3 bytes32[] calldata _publicFields4 ) public {Эта функция обрабатывает транзакции. Она получает доказательство (как bytes) и публичные входные данные (как массив bytes32) в формате, требуемом верификатором (для минимизации обработки в блокчейне и, следовательно, затрат на газ).
1 require(_publicInputs[0] == currentStateHash,2 "Wrong old state hash");Доказательство с нулевым разглашением должно доказывать, что транзакция изменяет наш текущий хэш на новый.
1 myVerifier.verify(_proof, _publicFields);Вызовите контракт верификатора для проверки доказательства с нулевым разглашением. Этот шаг отменяет транзакцию, если доказательство с нулевым разглашением неверно.
1 currentStateHash = _publicFields[1];23 emit TransactionProcessed(4 _publicFields[2]<<128 | _publicFields[3],5 _publicFields[0],6 _publicFields[1]7 );8 }9}Показать всеЕсли все в порядке, обновите хэш состояния до нового значения и сгенерируйте событие TransactionProcessed.
Злоупотребления со стороны централизованного компонента
Информационная безопасность состоит из трех атрибутов:
- Конфиденциальность — пользователи не могут читать информацию, на чтение которой они не авторизованы.
- Целостность — информация не может быть изменена, кроме как авторизованными пользователями авторизованным способом.
- Доступность — авторизованные пользователи могут использовать систему.
В этой системе целостность обеспечивается с помощью доказательств с нулевым разглашением. Доступность гораздо сложнее гарантировать, а конфиденциальность невозможна, потому что банк должен знать баланс каждого аккаунта и все транзакции. Невозможно помешать сущности, обладающей информацией, делиться этой информацией.
Возможно, удастся создать действительно конфиденциальный банк с использованием скрытых адресов (opens in a new tab), но это выходит за рамки данной статьи.
Ложная информация
Один из способов, которым сервер может нарушить целостность, — это предоставление ложной информации при запросе данных (opens in a new tab).
Чтобы решить эту проблему, мы можем написать вторую программу Noir, которая получает аккаунты в качестве приватного ввода и адрес, для которого запрашивается информация, в качестве публичного ввода. Выводом являются баланс и nonce этого адреса, а также хэш аккаунтов.
Конечно, это доказательство не может быть проверено в блокчейне, потому что мы не хотим публиковать nonce и балансы в блокчейне. Однако оно может быть проверено клиентским кодом, работающим в браузере.
Принудительные транзакции
Обычный механизм для обеспечения доступности и предотвращения цензуры на L2 — это принудительные транзакции (opens in a new tab). Но принудительные транзакции не сочетаются с доказательствами с нулевым разглашением. Сервер — единственная сущность, которая может проверять транзакции.
Мы можем изменить smart-contracts/src/ZkBank.sol, чтобы он принимал принудительные транзакции и не позволял серверу изменять состояние до их обработки. Однако это открывает нас для простой атаки типа «отказ в обслуживании». Что если принудительная транзакция недействительна и поэтому ее невозможно обработать?
Решение — иметь доказательство с нулевым разглашением того, что принудительная транзакция недействительна. Это дает серверу три варианта:
- Обработать принудительную транзакцию, предоставив доказательство с нулевым разглашением того, что она была обработана, и новый хэш состояния.
- Отклонить принудительную транзакцию и предоставить контракту доказательство с нулевым разглашением того, что транзакция недействительна (неизвестный адрес, неверный nonce или недостаточный баланс).
- Игнорировать принудительную транзакцию. Невозможно заставить сервер действительно обработать транзакцию, но это означает, что вся система недоступна.
Облигации доступности
В реальной реализации, вероятно, будет какой-то мотив прибыли для поддержания работы сервера. Мы можем усилить этот стимул, заставив сервер разместить облигацию доступности, которую любой может сжечь, если принудительная транзакция не будет обработана в течение определенного периода.
Неправильный код Noir
Обычно, чтобы заставить людей доверять смарт-контракту, мы загружаем исходный код в обозреватель блоков (opens in a new tab). Однако в случае доказательств с нулевым разглашением этого недостаточно.
Verifier.sol содержит ключ верификации, который является функцией программы Noir. Однако этот ключ не говорит нам, какой была программа Noir. Чтобы действительно иметь доверенное решение, вам нужно загрузить программу Noir (и версию, которая ее создала). В противном случае доказательства с нулевым разглашением могут отражать другую программу, с бэкдором.
Пока обозреватели блоков не начнут позволять нам загружать и проверять программы Noir, вам следует делать это самостоятельно (предпочтительно в IPFS). Тогда опытные пользователи смогут загрузить исходный код, скомпилировать его самостоятельно, создать Verifier.sol и убедиться, что он идентичен тому, что находится в блокчейне.
Заключение
Приложения типа Plasma требуют централизованного компонента для хранения информации. Это открывает потенциальные уязвимости, но взамен позволяет нам сохранять конфиденциальность способами, недоступными в самом блокчейне. С помощью доказательств с нулевым разглашением мы можем обеспечить целостность и, возможно, сделать экономически выгодным для того, кто управляет централизованным компонентом, поддерживать доступность.
Больше моих работ смотрите здесь (opens in a new tab).
Благодарности
- Джош Крайтс прочитал черновик этой статьи и помог мне с запутанным вопросом по Noir.
Ответственность за любые оставшиеся ошибки лежит на мне.
Последнее обновление страницы: 28 октября 2025 г.