Ковенанты дают Kaspa возможности смарт-контрактов, не выходя за рамки proof-of-work: скрипт на UTXO может проверить тратящую транзакцию и отклонить её, если условия не выполнены. Но скрипт, который проверяет только *как выглядят выходы*, недостаточен. Необходимо также проверять *сколько входов* содержит транзакция — и *сколько* может «съесть» комиссия майнерам. Два тонких класса атак эксплуатируют зазор между «выходы выглядят правильно» и «транзакция безопасна», и скрипты хранилища и эскроу Kaspa Forge закрывают обе уязвимости парой инвариантов, подтверждённых в ходе аудита безопасности в июле 2026 года.
В этой статье разбираются сами атаки, исправления и компромиссы в продакшене.
Проблема: два UTXO — один выход
Каждая точка входа ковенанта Kaspa — initiate, cancel, complete, release, dispute и остальные — это программа на Silverscript, которая анализирует тратящую транзакцию. Когда вы инициируете вывод из хранилища Kaspa Safe, скрипт проверяет, что выход идёт на предварительно зафиксированный адрес dest и что комиссия не превышает feeBudget. Всё просто.
Теперь представим пользователя, который пополнил хранилище двумя отдельными депозитами: UTXO A (1000 KAS) и UTXO B (10 KAS), оба на одном и том же P2SH-адресе. Злоумышленник получает доступ к горячему ключу и хочет вывести все средства.
Вот что происходит без инварианта одного входа:
Множественный вывод UTXO: злоумышленник конструирует одну транзакцию с обоими UTXO A и UTXO B в качестве входов. Суммарный вход — 1010 KAS. Скрипт ковенанта выполняется для каждого входа отдельно. Для входа A он проверяет, что некоторый выход на dest стоит не менее 1000 − feeBudget. Для входа B он проверяет, что некоторый выход на dest стоит не менее 10 − feeBudget. Обе проверки видят *один и тот же* выход — скажем, 1000 KAS на dest. Обе проходят. Оставшиеся 10 KAS (вся стоимость UTXO B) утекают в комиссию майнерам. Злоумышленник потратил средства хранилища, а 10 KAS испарились в награду за блок.
Корневая причина в том, что интерпретатор скриптов Kaspa проверяет каждый вход независимо, но все входы используют один и тот же набор выходов. Когда два UTXO ковенанта объединяются в транзакции, проверка каждого из них удовлетворяется одним и тем же выходом — а стоимость меньшего UTXO деваться некуда, кроме как в комиссию.
Это не теоретический крайний случай. Каждый раз, когда пользователь делает несколько депозитов на адрес хранилища, на этом адресе оказывается несколько UTXO. Скомпрометированный горячий ключ может их объединить.
Вторая атака: пополнение транзакции
Связанный вектор атаки даже не требует двух UTXO ковенанта. Злоумышленник берёт *один* UTXO хранилища и добавляет *свой собственный* нековенантный UTXO в качестве второго входа. Скрипт хранилища видит стоимость своего входа, проверяет выход — и пропускает. Но стоимость дополнительного входа теперь является частью транзакции — её можно перенаправить на любой выход или поглотить в комиссии. Злоумышленник фактически «пополняет» транзакцию собственными средствами, чтобы манипулировать структурой траты, а скрипт ковенанта понятия не имеет о существовании дополнительного входа.
Исправление: require(tx.inputs.length == 1)
Обе атаки исчезают благодаря одной строке в начале каждой точки входа:
require(tx.inputs.length == 1);
Если транзакция содержит более одного входа, скрипт немедленно её отклоняет — до какой-либо проверки подписи, до анализа выходов. Каждый UTXO ковенанта должен тратиться изолированно, в собственной транзакции, с собственными выходами. Нет второго входа, в который могла бы утечь стоимость, нет возможности пополнить транзакцию, нет общего выхода для эксплуатации.
База знаний разработчика вики Kaspa поясняет, что комиссии в Kaspa рассчитываются как разница между суммарной стоимостью входов и суммарной стоимостью выходов. Когда транзакция имеет только один вход, эта разница тривиально проверяема: fee = input_value − sum(outputs). С несколькими входами учёт комиссии переплетается между UTXO, которые могут нести разные правила скриптов — именно этот зазор и эксплуатируют данные атаки.
Проверка одного входа сводит эту сложность к нулю. Один вход, один выход (или небольшой фиксированный набор), один набор ограничений скрипта.
Ограничение feeBudget от злоупотребления
Инвариант одного входа решает проблему структурной утечки. Но существует и другой способ сжечь баланс хранилища: через *саму комиссию*.
Несколько точек входа Kaspa Forge являются безключевыми — они не требуют подписи вовсе. complete() (путь, финализирующий вывод после задержки) и inheritAuto() (путь наследования по принципу «мёртвого рубильника») могут быть отправлены кем угодно. Наблюдатель Safe отправляет их автоматически при выполнении условий. Любой узел в сети технически может их переслать.
Без ограничения комиссии тот, кто формирует безключевую транзакцию, сам решает размер комиссии майнерам. Злонамеренный наблюдатель — или любой, кто мониторит мемпул — может установить комиссию равной всей стоимости UTXO. Скрипт хранилища увидит fee ≤ feeBudget (если feeBudget был установлен равным стоимости UTXO при создании хранилища), пройдёт проверку, и весь баланс уйдёт майнерам. Безключевые пути по-прежнему «сработают» — они отправят ноль предполагаемому получателю и всё — в майнинговый пул.
Исправление Kaspa Forge, подтверждённое аудитом 11 июля:
int constant MAX_FEE_BUDGET = 10_000_000; // 0.1 KAS
require(feeBudget > 0 && feeBudget <= MAX_FEE_BUDGET);
Это утверждение присутствует в 6 из 7 точек входа хранилища и во всех 10 точках входа эскроу. Жёсткий потолок в 0,1 KAS (10 миллионов сомпи) означает, что даже если злоумышленник сформирует безключевую транзакцию, он сможет сжечь максимум 0,1 KAS комиссии — неприятно, но не катастрофично.
Бюджет комиссии — это параметр конструктора, задаваемый при создании хранилища. Внепривязки Kaspa Forge ограничивают допустимый диапазон значениями [1_000_000, 10_000_000] сомпи (0,01–0,1 KAS), предлагая пользователю разумное значение по умолчанию, в то время как ончейн-скрипт обеспечивает абсолютный потолок.
Единственное исключение: migrate
Путь migrate — единственная точка входа в обоих скриптах, которая не применяет ограничение feeBudget. Это намеренное решение.
Migrate требует оба ключа — горячий и тревожный — два независимых приватных ключа, которые владелец должен хранить раздельно. С двумя подписями владелец обладает полным правом определять любые выходы и любую комиссию. Требование двух подписей — это граница безопасности здесь, а не ограничение комиссии. Введение лимита на комиссию для migrate лишь ограничило бы владельца при экстренной ротации ключей или обновлении контракта.
Компромисс очевиден: компрометация *обоих* ключей означает мгновенную потерю — без задержки, без отмены тревоги и без ограничения комиссии. Это задокументировано в архитектуре хранилища как осознанный выбор проектирования — разделение двух ключей является красной линией, и пользователи должны её поддерживать.
Как Kaspa Forge реализует и проверяет это
Оба инварианта находятся в исходных файлах Silverscript — vault.sil (7 точек входа) и escrow.sil (10 точек входа) — которые компилируются в WASM-ядро на этапе сборки через include_str!. Те же исходные файлы используются как в браузерном WASM-модуле (для формирования транзакций на стороне клиента), так и в оффлайн-утилите vaultctl (для восстановления без какого-либо сервиса).
Проверка происходит на нескольких уровнях:
- Самотесты в виртуальной машине. Контракт хранилища содержит 18 самотестов; контракт эскроу — 52. Каждый тест проверяет путь траты с конкретными входами и ожидаемыми результатами, включая отклонение множественных входов. Они выполняются против интерпретатора скриптов узла Kaspa — того самого, который валидирует транзакции в сети.
- Доказательство
escrowctl multi-utxo. Оффлайн-утилитаescrowctl(часть инструментария Kaspa Forge) включает специализированный тест множественных UTXO, который конструирует транзакции с двумя входами ковенанта и проверяет, что скрипт их отклоняет. Это послужило основой для подтверждения аудита 10 июля.
- Ограничители во внепривязках. Rust-привязки (
bindings.rs), которые связывают WASM-ядро с браузером и сервером, отклоняют некорректные параметры до формирования транзакции:delay <= 0вызывает отказ, адрес наследника безinherit_delay > 0блокируется, а значения бюджета комиссии вне допустимого диапазона отвергаются на уровне API.
- Вычислительные бюджеты. Каждая точка входа объявляет вычислительный бюджет (
COMPUTE_BUDGET = 20для обычных путей,MIGRATE_BUDGET = 30для пути с двумя подписями). Это отдельный слой, ограничивающий стоимость исполнения скрипта и предотвращающий отказ в обслуживании через чрезмерно сложные скрипты.
Ключевой архитектурный момент в том, что эти проверки находятся в ончейн-скрипте, а не на сервере или в браузере. Пользователь, восстанавливающий средства через vaultctl на любом узле Kaspa v2+, получает ту же защиту. Сервер никогда не хранит ключи и не может обойти ограничения скрипта.
Компромиссы и текущие ограничения
Инвариант одного входа даётся не бесплатно. Он накладывает реальные ограничения:
Нет групповых выводов. Если пользователь пополнял хранилище 10 депозитами, инициация полного вывода потребует 10 отдельных транзакций initiate — по одной на каждый UTXO. Интерфейс Kaspa Safe (manage.html) справляется с этим с помощью функции withdrawAll, которая перебирает UTXO и формирует отдельную транзакцию для каждого, отправляя их все на один адрес разблокировки. Наблюдатель затем финализирует каждую независимо по истечении задержки. Это обходится дороже в сетевых комиссиях, чем единая групповая транзакция, но стоимость ограничена лимитом feeBudget на каждый UTXO.
Нет межковенантной композиции. Нельзя атомарно потратить UTXO хранилища и UTXO эскроу в одной транзакции. Каждый тип ковенанта живёт во вселенной собственных трат. Это осознанное ограничение — композиция внесла бы сложность кросс-скриптовой валидации, которую текущая модель Silverscript не поддерживает.
Мелкие депозиты дорого восстанавливать. UTXO стоимостью 0,05 KAS на адресе хранилища обходится при выводе (в комиссиях и накладных расходах транзакции) почти столько же, сколько содержит. Ограничение feeBudget помогает, ограничивая комиссию, но фиксированные накладные расходы каждой транзакции никуда не делись. Нет «сметающего» механизма, который обходил бы правило одного входа.
Лазейка migrate имеет собственную поверхность риска. Требование двух подписей — это надёжная защита, но оно также означает, что владелец должен неопределённо долго поддерживать два независимых ключа. Потеря тревожного ключа (или горячего) без резервной копии означает, что путь migrate заблокирован навсегда. Хранилище по-прежнему функционирует через обычный процесс вывода и наследования, но возможность мгновенного перемещения утрачена.
Если вы хотите увидеть, как инвариант одного входа и ограничение feeBudget работают в живом контракте, хранилище Kaspa Safe позволяет создать его прямо в браузере — скрипт, ключи и конструкторы транзакций работают на стороне клиента. Ни один фонд не покидает вашего контроля. Попробовать Kaspa Safe →
Описанные инварианты не уникальны для Kaspa Forge — это общие принципы проектирования ковенантов для любой UTXO-цепочки. Однако blockDAG Kaspa и скорость подтверждения в 10 блоков в секунду делают окно атаки особенно узким, а последствия утечки — особенно быстрыми. Проверка одного входа стоит одну строку кода. Альтернатива — доверять тому, что каждое формирование транзакции с несколькими входами будет идеальным — что на практике означает надеяться, что злоумышленник не найдёт зазор.
FAQ
Что такое инвариант одного входа в ковенанте Kaspa?
Правило на уровне скрипта — require(tx.inputs.length == 1) — которое заставляет каждую тратящую транзакцию использовать ровно один UTXO из контракта. Оно не позволяет злоумышленнику объединять несколько UTXO ковенанта в одной транзакции и выводить стоимость через комиссию.
Что такое атака на множественный вывод UTXO?
Когда два UTXO по одному адресу ковенанта тратятся вместе, скрипт проверяет условия каждого входа независимо, но по отношению к общему набору выходов. Вся стоимость меньшего UTXO может утечь в комиссию майнеров, при этом обе проверки проходят успешно.
Что такое ограничение feeBudget от злоупотребления?
Утверждение в ковенанте — require(feeBudget > 0 && feeBudget <= 10_000_000) — которое ограничивает максимальную комиссию майнерам для любого безключевого пути траты до 0,1 KAS. Без этого ограничения любой, кто может отправить безключевую транзакцию, способен сжечь весь баланс хранилища в качестве комиссии.
Инвариант одного входа применяется ко всем точкам входа Kaspa Forge?
Да. Все 17 точек входа — 7 в Kaspa Safe (vault.sil) и 10 в Kaspa Escrow (escrow.sil) — начинаются с require(tx.inputs.length == 1).
Почему путь migrate не ограничен feeBudget?
Migrate требует оба ключа — горячий и тревожный. С двумя подписями владелец полностью контролирует выходы и комиссии — ограничение комиссии лишь помешало бы законному владельцу при экстренной ротации ключей или обновлении контракта.
Можно ли по-прежнему группировать несколько выводов из хранилища?
Нет, и это намеренно. Каждый UTXO хранилища должен выводиться отдельной транзакцией. Интерфейс Kaspa Safe обрабатывает это с помощью функции withdrawAll, которая создаёт отдельную транзакцию initiate для каждого UTXO.
Держите KAS там, где кражу можно отменить
Ковенант-сейф в мейннете Kaspa: ваши ключи, ваши правила, наш инструмент. Ончейн — бесплатно, навсегда.
Создать сейф
