Ковенант — это контракт, живущий на блокчейне и проверяющий каждый байт транзакции, которая его тратит. Если байт, который ожидает ковенант, и байт, который выдаёт кошелёк, отличаются хотя бы на одну позицию, транзакция проваливается. Никакого «почти правильно» здесь не существует. Для браузерного кошелька, собирающего и подписывающего транзакции Kaspa внутри песочницы WebAssembly, доказательство побайтовой идентичности с нативной сборкой Rust — это не приятный бонус, а тест на допуск, определяющий, можно ли вообще доверять браузеру.
В этой статье объясняется, как Kaspa Forge доказывает байтовую идентичность Kaspa WASM: гарантию того, что один и тот же исходный код Rust, скомпилированный в нативный код на сервере и в WebAssembly в браузере, создаёт идентичные байты транзакций при одинаковых входных данных. Мы рассмотрим саму проблему, архитектуру крейтов, делающую решение возможным, механическую проверку идентичности и то, как она работает в реальном допуске ковенантов для продуктов Arena и Kaspa Safe.
Проблема: две сборки, одна транзакция
Каждая транзакция в Kaspa — это блок детерминированных байтов: версия, входы (каждый со ссылкой на UTXO и скриптом), выходы (с суммами и скриптами), идентификатор подсети, газ, полезная нагрузка и время блокировки. Txid — это Blake2b-хеш этих байтов. Измени один байт — и txid изменится, а значит, изменится UTXO, который транзакция тратит или создаёт, а значит, любой ковенант, привязанный к конкретной структуре выхода, отклонит эту трату.
Теперь рассмотрим жизненный цикл транзакции в браузерном кошельке:
1. Наблюдение. Кошелёк считывает данные блокчейна — набор UTXO, скрипты, суммы, заголовки блоков — из ноды. 2. Допуск. Кошелёк проверяет, что UTXO с ковенантом соответствует тому, что заявляет приложение. Для этого нужно заново собрать ожидаемый скрипт из опубликованной конфигурации и побайтово сравнить его с тем, что записано в блокчейне. (Kaspa wiki документирует лежащие в основе типы консенсуса, на которые ссылаются эти скрипты.) 3. Сборка. Кошелёк формирует неподписанную транзакцию — выбирает входы, вычисляет комиссии, конструирует выходы с правильными скриптами ковенантов. 4. Подпись. Кошелёк применяет подписи Schnorr к входам транзакции.
Если шаги 2 и 3 выполняются в нативном Rust на сервере, разработчик контролирует компилятор, дерево зависимостей и среду выполнения. Если они выполняются в WebAssembly внутри вкладки браузера, скомпилированный артефакт обязан выдавать те же байты — иначе проверка допуска на шаге 2 становится фикцией: вы проверили одну транзакцию, а подписали другую.
Это не гипотетический риск. JavaScript-слой между «проверенным» и «подписанным» способен незаметно подменить поля. Другой порядок сериализации в JS-объекте может дать внешне корректный, но побайтово отличающийся результат. Различия в числовой точности между JS Number (IEEE 754 double) и Rust u64 способны исказить суммы. Ковенанту всё равно, что вы имели в виду; он проверяет байты.
Архитектура: три крейта, один генератор
Решение начинается с чёткого разделения ответственности между тремя крейтами, каждый с осознанно ограниченным бюджетом зависимостей.
arena-core: свод правил без привязки к блокчейну
Это Rust-крейт с no_std — без аллокатора, без ввода-вывода, без типов Kaspa. Он определяет правила игры, хеш-домены, коды отказа допуска и логику верификации, проверяющую транзакцию на соответствие набору ограничений. Поскольку у него нет зависимости от kaspa-consensus-core или какой-либо сетевой библиотеки, он компилируется и в RISC-V гостевой бинарник для верификации доказательств с нулевым разглашением, *и* в браузерный WASM-модуль — без подключения полного движка консенсуса.
Граница no_std — это не вопрос стиля. Если бы arena-core импортировал тип Kaspa, это изменило бы гостевой ELF-бинарник, что изменило бы image_id, что сделало бы недействительными четыре рецепта Groth16 — дорогая ошибка.
arena-program: детерминированный конструктор транзакций
Это крейт, который непосредственно *собирает* вещи: скрипты, хеш-шаблоны, манифесты, политики комиссий, константы тайм-аутов, идентификаторы программ. Его допустимые зависимости сужены до предела:
kaspa-forge-arena-core # path dependency — the rulebook above
kaspa-consensus-core # types only: amounts, outpoints, scripts
kaspa-txscript # script opcodes and emission
blake2b_simd # hashing
hex # encoding
Обратите внимание, чего здесь нет: kaspa-consensus (полный движок консенсуса), tokio (асинхронная среда выполнения), risc0-zkvm (доказатель), secp256k1 (подпись). Крейт-генератор не умеет подписывать, доказывать, взаимодействовать с нодой и выполнять скрипты над набором UTXО. Он умеет только *собирать*.
Ключевой тип — ProgramRebuild — непрозрачная структура с приватными полями и без публичного конструктора:
pub struct ProgramRebuild {
// fields are private — no Default, no Deserialize, no from_parts
}
impl ProgramRebuild {
// The only way to obtain one:
pub fn rebuild_program(config: &RoomConfig, pins: &RoomPins) -> Self { ... }
}
Это принцип «дверь, закрытая типом». Внешний крейт не может вручную сконструировать ProgramRebuild. Единственный способ получить его — вызвать rebuild_program, который запускает канонический генератор. Если у вас есть ProgramRebuild, значит, вы запустили настоящий генератор — именно этот инвариант обеспечивает система типов.
Детерминированный конструктор транзакций: кодовый путь, который при одинаковых входных данных (состояние блокчейна, конфигурация и политика) всегда выдаёт побайтово идентичный результат транзакции независимо от целевой платформы компиляции — нативной x86_64 или WebAssembly. Детерминированность здесь означает полную байтовую последовательность, а не просто семантическую эквивалентность.
arena-client-admission: шлюз, читающий блокчейн
Этот крейт — единственный, которому разрешено наблюдать за блокчейном. Он вызывает rebuild_program из arena-program, чтобы воссоздать то, как Room *должен* выглядеть, считывает фактический UTXO из ноды через трейт RoomObserver и сравнивает байты. Шлюз допуска возвращает Result<(), Vec<Refusal>> — варианта «примерно нормально» не существует.
Критическая цепочка типизированна, а не текстовая:
ChainReader → VerifiedProgram → VerifiedRoom → VerifiedJoinTransaction → signature
Каждый шаг выдаёт типизированный результат, который потребляет следующий шаг. Нельзя пропустить VerifiedProgram и перейти к VerifiedRoom; промежуточные типы невозможно сконструировать извне.
Доказательство идентичности: одни байты, разные платформы
Поскольку генератор изолирован в arena-program, вопрос идентичности становится механическим: выдаёт ли arena-program одни и те же байты при компиляции в нативный код и в WASM?
Ответ проверяется скриптом scripts/wasm-parity.sh, который:
1. Компилирует arena-wallet-wasm — тонкий крейт, ре-экспортирующий verify_and_build_join из arena-program как единственный WASM-экспорт — в файл .wasm. 2. Компилирует ту же функцию как нативную библиотеку Rust. 3. Вызывает оба с одинаковыми входными данными: те же наблюдения за блокчейном, та же конфигурация Room, те же данные UTXO. 4. Сравнивает результирующие массивы байтов поэлементно.
# Simplified sketch of the parity check
native_output=$(run_native_verify_and_build_join "$input")
wasm_output=$(run_wasm_verify_and_build_join "$input")
diff <(echo "$native_output") <(echo "$wasm_output")
Если diff непустой — тест провален. Никакой допустимой погрешности, никакого нечёткого сравнения, никакого «достаточно хорошо для браузера». Закреплённая ревизия rusty-kaspa (98a4ccd8) идентична в крейте-генераторе и в arena-wallet-wasm, поэтому обе сборки видят одни и те же типы консенсуса, одинаковый порядок полей, одну и ту же логику сериализации. Другая ревизия означала бы другие определения типов, а значит — другие байты.
Дизайн arena-wallet-wasm с единственным экспортом — это осознанное решение. Если бы крейт предоставлял мелкозернистые функции — «собери входы здесь, прикрепи выходы туда, теперь сериализуй» — то JavaScript-код оказался бы между проверенной структурой и финальной сборкой байтов. Кто-нибудь рано или поздно «подправил» бы объект в этом JS-слое. Единственный экспорт означает, что весь путь от наблюдения до неподписанной транзакции происходит внутри одного вызова Rust. JavaScript получает только готовый, запечатанный массив байтов и вычисленный txid.
Вычислительная масса: скрытый байт
Байтовая идентичность — это не только порядок полей и содержимое скриптов. Она распространяется на *вес* транзакции — её подписанную массу, которая определяет комиссию.
Каждый вход транзакции Kaspa несёт вычислительный бюджет: обязательство по количеству скриптовых единиц, которое нода затратит на его валидацию. Одна проверка подписи Schnorr стоит 100 000 скриптовых единиц; при 10 000 единиц на единицу бюджета это даёт вычислительный бюджет, равный 10. Этот бюджет входит в размерность вычислительной массы общей массы транзакции.
Транзакция, собранная без правильного вычислительного обязательства — скажем, с бюджетом 0, — была бы легче примерно на тысячу граммов вычислительной массы. Пул памяти ранжирует по ставке комиссии (fee / mass), поэтому более лёгкая транзакция при той же комиссии имеет более высокую ставку и способна вытеснить каноническую форму. Ковенант фиксирует класс скрипта (P2PK, требующий OpCheckSig) именно для того, чтобы идентичность массы была следствием привязки скрипта, а не отдельной проверкой.
Для браузера это означает, что WASM-сборка должна вычислять ровно ту же массу, что и нативная сборка. Крейт arena-program измеряет нормализованную подписанную массу в граммах и применяет ставку комиссии ровно один раз:
required_fee_sompi = signed_mass_grams × feerate_sompi_per_gram
Двойное применение ставки комиссии — сначала по базовой ставке, затем по текущей — был реальным дефектом, пойманным на ревизии Gate 7. Модель «масса прежде всего», при которой границы хранятся в граммах, а ставка применяется единожды, является частью детерминированного контракта между нативной и браузерной сборками.
Продукты Kaspa Forge — Kaspa Safe, Escrow и Deposit — используют ковенантные транзакционные пути, где браузер обязан произвести байты, которые ончейн-контракт примет. Та же модель детерминированного конструктора — Rust, скомпилированный в WASM, единственный экспорт, тестирование идентичности относительно нативной сборки — лежит в основе построения транзакций во всём семействе продуктов. Ключи остаются на вашем устройстве; код является открытым.
Компромиссы и честные границы
Доказательство идентичности имеет реальные ограничения, о которых стоит сказать:
Тест идентичности доказывает структурное совпадение, а не семантическую корректность. Если обе сборки — нативная и WASM — производят одни и те же неправильные байты из-за логического бага в генераторе, тест идентичности всё равно пройден. Сравнение с эталонным файлом (program-golden) и негативные тесты шлюза допуска отлавливают семантические ошибки, но это отдельные проверки.
Тест идентичности не запускается в CI на каждой платформе. Он запускается на закреплённой ревизии rusty-kaspa с закреплённым тулчейном Rust. Обновление компилятора, меняющее поведение с плавающей запятой (здесь нерелевантно — код использует целые числа, — но показательно для данного класса рисков), не будет обнаружено, пока кто-нибудь не запустит скрипт вручную.
Трейт RoomObserver — это граница доверия, которую доказательство идентичности не способно закрыть. У arena-wallet-wasm нет доступа к ноде. Кто-то должен реализовать RoomObserver — трейт, возвращающий данные блокчейна, — и злонамеренная реализация может вернуть сфабрикованные UTXO. Доказательство идентичности гарантирует, что *конструктор* детерминирован; оно не гарантирует, что *входные данные* честны. Для Arena отдельный путь допуска через FileRoomRegistry обеспечивает поступление данных блокчейна от доверенного координатора, а не из браузера. Для Kaspa Safe и других продуктов Kaspa Forge браузер читает напрямую из ноды, а сам ковенант обеспечивает корректность на уровне блокчейна.
overflow-checks = true в профиле релизной сборки — это страховка, а не доказательство. Арифметика генератора проверяется на переполнение во время выполнения, и тот же флаг установлен в обеих сборках — нативной и WASM, — обеспечивая идентичное поведение при панике. Однако переполнение отлавливается во время выполнения, а не при компиляции. Путь, который никогда не проходится в тесте идентичности, всё равно может дать разное переполнение при расхождении входных данных.
Безголовый (внебраузерный) путь для закрытого канареечного релиза Arena использует те же библиотечные вызовы нативно — wallet-wasm компилируется как rlib именно для этой цели. Бинарник canary-identity.rs вызывает derive_canary_keyset и create_invite_game_identity без браузера, выдавая побайтово идентичный результат экспорту WASM в Desk. Так работает жизненный цикл канареечного релиза — без необходимости человеку проходить 15-минутное окно испытания Room, — и это доказывает идентичность в обратном направлении: нативные вызовы выдают те же байты, что и браузерные.
Основная гарантия остаётся в силе: если тест идентичности пройден, эталонный файл генератора совпадает, а негативные тесты шлюза допуска отклоняют каждую известную подмену, — то транзакция, собранная в браузере, идентична транзакции, которую одобрил нативный шлюз допуска. Для денег, привязанных к ковенанту — будь то Arena Room, хранилище Kaspa Safe или контракт Escrow, — это минимальный порог.
---
*Примечание о статусе: Arena Blackjack — публичная бета на основной сети. Dice — инженерная работа с отключённым постоянным продакшн-контуром до прохождения ревизии. Duel, baccarat и свободно выпускаемые токены — пункты дорожной карты, а не запущенные продукты. Описанная здесь система байтовой идентичности WASM — это рабочая технология, применяемая в текущем пути допуска Arena.*
FAQ
Что означает «байтовая идентичность WASM» для транзакции Kaspa?
Это означает, что точная последовательность байтов, определяющая транзакцию — входы, выходы, скрипты, суммы и комиссии, — идентична независимо от того, была ли транзакция собрана нативным бинарным файлом Rust на сервере или тем же кодом Rust, скомпилированным в WebAssembly и работающим во вкладке браузера. Один единственный отличающийся байт приводит к другому txid.
Почему браузер не может просто использовать JavaScript-конструктор транзакций?
JavaScript не гарантирует детерминированность в отношении точности числовых вычислений, порядка полей и сериализации. JS-конструктор может создать транзакцию, которая выглядит правильной, но отличается от той, что прошла через нативный шлюз допуска. Вся модель доверия рушится, если проверяемый объект и подписанный объект собраны разным кодом.
Как именно тестируется байтовая идентичность?
Shell-скрипт (wasm-parity.sh) компилирует один и тот же Rust-крейт и в нативную библиотеку, и в WASM-модуль, вызывает обе с одинаковыми входными данными и сравнивает выходные массивы байтов. Любое расхождение проваливает тест. Закреплённая ревизия rusty-kaspa гарантирует, что обе сборки видят одни и те же типы консенсуса.
Применяется ли байтовая идентичность Kaspa WASM ко всем продуктам Kaspa Forge?
Доказательство идентичности было создано для пути допуска ковенантов Arena — самого строгого сценария: один неверный байт означает, что ковенант отклонит транзакцию или игрок потеряет средства. Та же модель детерминированного конструктора лежит в основе построения транзакций во всех продуктах Kaspa Forge — Kaspa Safe, Escrow, Deposit и других, где используется подпись в браузере.
Что произойдёт, если тест идентичности провален?
Сборка отклоняется. Никакого «мягкого» отката не предусмотрено — расхождение между нативным и WASM-результатом означает, что браузеру нельзя доверять сборку транзакций, которые ончейн-ковенанты примут. Исправление должно быть внесено в исходный код Rust, а не во внешний слой согласования.
Исходный код WASM-модуля открыт?
Да. Крейт arena-wallet-wasm, детерминированный генератор (arena-program) и правила допуска (arena-client-admission) — это открытый код на Rust. Скрипт теста идентичности является частью репозитория. Ключи остаются на устройстве пользователя.
Держите KAS там, где кражу можно отменить
Ковенант-сейф в мейннете Kaspa: ваши ключи, ваши правила, наш инструмент. Ончейн — бесплатно, навсегда.
Создать сейф
