Precompile-контракты: расширение 2D без EVM
Сеть, которая умеет только перемещать средства, имеет ограниченное применение. Кредитование, эскроу, свопы, авторизованные записи оракулов, атомарные вводы и выводы — любому серьезному стеку требуется on-chain логика, которая сложнее, чем просто «списать у A, зачислить B».
Стандартный ответ индустрии — это EVM, универсальный интерпретатор байт-кода, выполняющий произвольный код, который может развернуть (задеплоить) любой желающий. Цена такого решения известна: необходимость учета газа (gas metering), язык программирования, построенный вокруг этого учета, поверхность атаки самой виртуальной машины и открытый вопрос о том, что произойдет, если недоверенный код сделает с состоянием что-то неожиданное.
2D пошла другим путем, внедрив компактный явный реестр precompile-контрактов. Каждый «контракт» представляет собой Elixir-модуль, который помещается в один файл и читается от начала до конца. Он реализует фиксированный интерфейс (behaviour) и располагается по фиксированному адресу в пространстве имен 0x2D00…. Здесь нет интерпретатора байт-кода и нет слоя учета газа, в котором можно допустить ошибку. Набор исполняемого кода закрыт: precompile-контракты развертывает сам оператор узла, поэтому всегда известно, что именно будет выполняться.
В этой статье мы последовательно разберем: как транзакция находит нужный precompile, что требует интерфейс @behaviour Chain.Precompile от реализации, где находится реестр, как выглядит рабочий HTLC precompile и почему эта модель строже и безопаснее wrapped-моста.
Как транзакция находит precompile
Заголовок раздела «Как транзакция находит precompile»У каждой транзакции есть адрес назначения (to). Производитель блоков обрабатывает транзакцию по основному пути: декодирует подписанную транзакцию, проверяет nonce, а затем запрашивает у реестра precompile-контрактов, принадлежит ли адрес to зарегистрированному обработчику. Если обработчик найден, происходит диспетчеризация вызова в его функцию execute/3. Если не найден, транзакция обрабатывается как нативный перевод USDC. На второй путь приходится 99% трафика, так как обычный перевод между аккаунтами не является precompile-контрактом — это поведение по умолчанию.
Код диспетчеризации (lib/chain/block_executor.ex:284-318):
defp charge_and_execute(pending, tx, from, to, value, fee, block_number, tx_index, ts) do case to && Chain.Precompiles.Registry.lookup(to) do {:ok, handler} -> cond do Decimal.compare(value, Decimal.new(0)) != :eq -> {:error, :precompile_value_not_supported}
true -> with {:ok, input} <- Crypto.decode_hex_safe(tx.input), true <- byte_size(input) >= 4 do <<selector::binary-4, args::binary>> = input run_precompile_execute(handler, selector, args, input, from, value, ...) else _ -> {:error, :invalid_calldata} end end
_ -> # нативный USDC-перевод: списать с отправителя, зачислить получателю, взять комиссию. ... endendДве ветки, одно решение. Зарегистрирован? Вызываем обработчик. Не зарегистрирован? Переводим USDC. Никакой другой произвольный код не выполняется; вся кастомная логика проходит исключительно через precompile.
Интерфейс Chain.Precompile
Заголовок раздела «Интерфейс Chain.Precompile»Каждый precompile-контракт реализует три колбэка (lib/chain/precompiles/precompile.ex):
defmodule Chain.Precompile do @callback address() :: binary()
@callback execute(selector :: binary(), args :: binary(), context :: Chain.Precompile.Context.t()) :: {:ok, result :: binary(), logs :: list()} | {:revert, reason :: binary()}
@callback read(selector :: binary(), args :: binary()) :: {:ok, abi_encoded :: binary()} | {:revert, reason :: binary()}endaddress/0. Возвращает адрес, по которому доступен данный precompile. Адреса жестко заданы в диапазоне0x2D00…, чтобы весь набор можно было легко перечислить.execute/3. Точка входа для операций, изменяющих состояние. Вызывается внутри транзакции базы данных при формировании блока. Возвращает результат и список логов, которые попадают в tx-receipt, либо кортеж{:revert, reason}для чистого отката изменений.read/2. View-вызов (только для чтения). Используется вeth_callиtriggerconstantcontract. Состояние не изменяет.
Структура context, передаваемая в execute/3, содержит данные, аналогичные msg.sender и msg.value в Solidity, а также высоту блока, timestamp блока и индекс транзакции (tx-index). Это всё, что нужно обработчику, чтобы понять контекст вызова. Лаконичность этого интерфейса — сознательное решение: функцию execute/3, которая может только явно изменять аккаунты и таблицы через локальные модули, проверять (ревьюить) значительно проще, чем полноценную виртуальную машину EVM.
Реестр (lib/chain/precompiles/registry.ex) представляет собой ETS-таблицу, где ключом является адрес:
def lookup(address) when is_binary(address) do case :ets.lookup(@table, address) do [{^address, module}] -> {:ok, module} [] -> :not_found endend
def register(address, handler_module) do GenServer.call(__MODULE__, {:register, address, handler_module})endПри запуске узел загружает активные precompile-контракты один раз, и в дальнейшем каждая транзакция требует лишь быстрого поиска в ETS (ETS-lookup), что занимает считанные микросекунды. Добавление нового precompile-контракта сводится к одному вызову register/2 и развертыванию соответствующего модуля. Никакой передачи байт-кода через mempool: операторы выкатывают precompile-контракты тем же способом, что и остальной код узла.
Пространство имен адресов также имеет значение. Все системные precompile-контракты располагаются в диапазоне 0x2D00…; старший байт 0x2D (ASCII-код символа -, от названия проекта) является зарезервированным префиксом. Вызов eth_getCode возвращает 0x01 для любого существующего адреса в этом диапазоне. Таким образом, кошельки, которые перед вызовом проверяют наличие контракта по адресу через eth_getCode, получают корректный ответ без необходимости реализации полноценной EVM.
HTLC precompile
Заголовок раздела «HTLC precompile»Перейдем от абстракций к коду. HTLC precompile по адресу 0x2D00…0001 — это рабочий код сети, управляющий состоянием (lib/chain/precompiles/htlc.ex).
HTLC (Hashed Time-Locked Contract) — это базовый примитив для атомарных свопов внутри сети. Alice блокирует средства с хешем H и дедлайном. Любой, кто знает preimage P (где sha256(P) = H), может забрать средства до истечения дедлайна. Если никто не забрал средства, Alice может вернуть их себе (refund). Суть в том, что две стороны в разных сетях могут запустить парные HTLC и получить своп, который либо полностью завершается (preimage раскрывается с обеих сторон), либо полностью откатывается (оба дедлайна истекают, обе стороны получают refund). Доверять некому: ни посреднику, ни федерации валидаторов, ни единому контракту, в котором хранятся все заблокированные средства.
defmodule Chain.Precompiles.HTLC do @moduledoc """ Hashed time-locked contract: атомарные свопы без доверенного моста. """ @behaviour Chain.Precompile
@address <<0x2D, 0::144, 0x01>> # 0x2D00…0001
# 4-байтные Keccak-256-селекторы @lock_selector selector("lock(bytes32,address,uint256,uint256)") @claim_selector selector("claim(bytes32,bytes32)") @refund_selector selector("refund(bytes32)") @get_swap_selector selector("getSwap(bytes32)")
defp selector(signature), do: :erlang.binary_part(ExKeccak.hash_256(signature), 0, 4)
@impl true def address, do: @address
@impl true def execute( @lock_selector, <<hash::binary-32, _pad::binary-12, receiver::binary-20, amount::256, deadline_ms::256>>, ctx ) do do_lock(hash, receiver, amount, deadline_ms, ctx) end
def execute(@claim_selector, <<hash::binary-32, preimage::binary-32>>, ctx) do do_claim(hash, preimage, ctx) end
def execute(@refund_selector, <<hash::binary-32>>, ctx) do do_refund(hash, ctx) end
def read(@get_swap_selector, <<hash::binary-32>>), do: do_get_swap(hash)endПолный код обработчика достаточно компактен, чтобы его можно было легко проанализировать. Четыре селектора, одна строка в state.htlc_swaps на каждый хеш, строгие предусловия для каждой ветки и все изменения балансов происходят внутри SERIALIZABLE-транзакции базы данных. Никаких уязвимостей типа reentrancy, скрытых циклов или сюрпризов с расчетом газа.
Мост использует ту же модель precompile-контракта по адресу 0x2D00…0003: BridgeRefillMint предоставляет только функцию bridge_lock(...), которая атомарно вставляет запись bridge_mints и привязанный к получателю HTLC, а проверку межсетевого доказательства оставляет верификатору. Подробный процесс описан в статье про мост.
NativeTransferAuth по адресу 0x2D00…0006 — EIP-3009-образный метод для нативного USDC: transferWithAuthorization против state.accounts, не ERC-20. Именно этот asset объявляет x402 на eip155:77. Permit2 и Ethereum mainnet USDC отклоняются.
Чем это лучше моста
Заголовок раздела «Чем это лучше моста»Обычный способ переноса активов между сетями — это wrapped-мост. Alice отправляет USDC в custody-контракт в сети A, федерация валидаторов фиксирует блокировку, и в сети B для нее выпускается обернутое (wrapped) представление токенов. Это знакомая, широко распространенная и критически уязвимая схема: с 2020 года из cross-chain мостов было украдено более $2.8B, что составляет примерно 40% от всего объема украденных средств в Web3 (Chainalysis / отраслевой обзор). Только в 2026 году зафиксировано более $750M потерь на мостах менее чем за четыре месяца (Phemex: DeFi-хаки 2026).
Громкие провалы читаются как каталог компрометаций валидационного слоя:
- Ronin (2022, ~$620M). Фишинг пяти из девяти ключей валидаторов; атакующий подписал два огромных вывода средств.
- Wormhole (2022, ~$320M). Некорректное использование Solana-helper-а позволило принять поддельную подпись guardian; wETH были выпущены из воздуха.
- Nomad (2022, ~$190M). Обновление случайно отключило критическую проверку, и мост превратился в «бери кто хочешь».
- Poly Network (2021, ~$611M). Функция
lockмежсетевого менеджера позволяла инициировать unlock произвольной суммы.
HTLC precompile не имеет федерации валидаторов, которую можно скомпрометировать, не имеет подписи, которую можно подделать, не имеет пути обновления (upgrade path), в котором можно допустить ошибку, и не концентрирует TVL в одном контракте, который становится очевидной мишенью. Вся его модель доверия сводится к двум условиям: «вы знаете preimage» и «в этой сети есть дедлайн».
| Характеристика | Wrapped-мост (Wormhole / Ronin / Nomad / …) | HTLC precompile |
|---|---|---|
| Модель доверия | N-of-M федерация валидаторов, кастодиан или оракул | sha256(preimage) = hash + on-chain дедлайн |
| Успешный путь | Кастодиан одобряет wrap/unwrap | Раскрытие preimage, средства переводятся |
| Путь отказа | Компрометация валидаторов приводит к потере всего пула | Дедлайн истекает, обе стороны получают refund |
| Концентрация TVL | Да. Миллиарды в одном контракте | Нет. Каждая блокировка независима и ограничена своей суммой |
| Риск обновления | Multisig может обновлять контракт (дополнительный вектор атаки) | Развертывание обработчика — это обычный релиз оператора; адрес фиксирован |
| Что хранит сеть | Контракт, который доверяет off-chain-подписантам | Весь автомат состояний, каждая ветка выполнения |
| Украдено с 2020 года | $2.8B+ | Худший случай — один пользователь теряет одну блокировку |
Последняя строка — самая важная. Ошибка в мосту по своей природе системна: одна уязвимость приводит к потере всего пула. Сбой в HTLC изолирован на уровне отдельного свопа (per-swap): если одна сторона не успевает выполнить claim или refund, она теряет максимум то, что заблокировала в конкретной транзакции. Не существует модели угроз «слить весь мост», потому что самого моста как единой точки отказа нет.
Архитектура precompile-контрактов также решает одну неочевидную проблему мостовых свопов. Мост — это смарт-контракт, а смарт-контракты в виртуальной машине безопасны ровно настолько, насколько безопасны их реализация и механизмы обновления. Precompile — это Elixir-модуль, прошедший ревью перед развертыванием; его путь обновления — это релиз узла, а не multisig-транзакция; структура состояния статически известна в таблицах Postgres. Поверхность атаки смещается с вопроса «делает ли контракт то, что написано в whitepaper» к вопросу «соответствует ли Elixir-код спецификации», а это задача, которую формальные методы позволяют решать в промышленном масштабе.
Компромиссы относительно EVM
Заголовок раздела «Компромиссы относительно EVM»Модель precompile-контрактов — это не бесплатный обед. Она действительно жертвует рядом свойств:
- Нет развертывания от третьих лиц. Нельзя задеплоить новый precompile, просто отправив байт-код в mempool. Добавление precompile-контракта — это релиз оператора, как и любая другая функция узла. Это преимущество, если нужен контролируемый (curated) набор кода, и недостаток, если требуется открытая permissionless-платформа.
- Меньшая выразительность. Precompile — это один Elixir-модуль с фиксированной точкой входа. Нельзя построить произвольную (ad-hoc) композицию двух недоверенных контрактов, как это делается в EVM. Если двум precompile-контрактам нужно взаимодействовать, это взаимодействие программирует оператор на Elixir.
- Меньшая экосистема. Весь стек dApp в EVM-совместимых сетях построен вокруг Solidity. 2D работает иначе: интегрирующимся приложениям необходимо обращаться к ABI каждого precompile-контракта напрямую, и готовый инструментарий Solidity (toolchain) из коробки не подойдет.
Что мы получаем взамен:
- Каждый путь выполнения в сети — это код на Elixir, который можно прочитать и проверить. Он типизирован, тестируем, запускается в REPL и ограничен размером файла модуля.
- Отсутствие сложности учета газа. Учет газа нужен для того, чтобы прерывать недоверенный код, который может выполняться бесконечно. Код precompile-контрактов является доверенным, поэтому ему не нужен пошаговый учет (per-opcode-metering). Благодаря этому модель газа всей сети может быть значительно проще: не требуется пошаговая бухгалтерия, так как отсутствуют потенциально вредоносные опкоды.
- Отсутствие поверхности атаки виртуальной машины. Нет багов EVM, нет эксплойтов байт-кода, нет тонких уязвимостей при взаимодействии опкодов. Поверхность атаки ограничена несколькими precompile-модулями и самой виртуальной машиной BEAM. Это существенное сокращение рисков.
Стратегия безопасности
Заголовок раздела «Стратегия безопасности»Закрытый, проверяемый набор precompile-контрактов открывает возможности для верификации кода, которые крайне сложно реализовать, когда набор кода представляет собой «что угодно, что кто угодно может задеплоить»:
- Tier 0 (базовый уровень, сегодня). Использование Dialyzer и set-theoretic типов в Elixir 1.20 дает precompile-модулям строгие статические гарантии. Спецификации колбэков
@behaviourуже отсеивают целый класс ошибок, связанных с некорректным вводом (malformed input). - Tier 1. Property-based тестирование с помощью PropCheck, которое прогоняет
execute/3со случайными парами selector+args для проверки инвариантов на уровне модуля (например, «сумма балансов сохраняется», «блокировка существует тогда и только тогда, когда она есть в хранилище»). - Tier 2 (конкурентность). Инструмент Concuerror перебирает все возможные порядки выполнения операций ETS между реестром, обработчиком и производителем блоков. Это позволяет находить состояния гонки (race conditions) и взаимные блокировки (deadlocks), которые обычные тесты не выявляют.
- Tier 3 (протокол). Спецификации TLA+ (генерируемые в исполняемый Erlang-код через Erla+) для precompile-контрактов, корректность которых зависит от многошагового протокола, а не от одной функции. Канонический пример: HTLC с инвариантом «completed XOR rolled_back, никогда частично».
Каждый уровень сложнее и надежнее предыдущего. Суть в том, чтобы внедрять их постепенно, по мере появления реальных precompile-контрактов, а не авансом.
Куда это ведет
Заголовок раздела «Куда это ведет»Первые precompile-контракты с реальным состоянием уже работают: HTLC-расчет, bridge refill/mint и нативный transferWithAuthorization для x402. Следующий шаг — усиление стека верификации вокруг них. Dialyzer и типы уже интегрированы в CI, далее последуют точечные проверки свойств (PropCheck) для обработчиков с состоянием и TLA+ спецификации для HTLC, а также для будущих precompile-контрактов, где корректность определяется протоколом, а не отдельной функцией.