Перейти к содержимому

Precompile-контракты: расширение 2D без EVM

Сеть, которая умеет только перемещать средства, имеет ограниченное применение. Кредитование, эскроу, свопы, авторизованные записи оракулов, атомарные вводы и выводы — любому серьезному стеку требуется on-chain логика, которая сложнее, чем просто «списать у A, зачислить B».

Стандартный ответ индустрии — это EVM, универсальный интерпретатор байт-кода, выполняющий произвольный код, который может развернуть (задеплоить) любой желающий. Цена такого решения известна: необходимость учета газа (gas metering), язык программирования, построенный вокруг этого учета, поверхность атаки самой виртуальной машины и открытый вопрос о том, что произойдет, если недоверенный код сделает с состоянием что-то неожиданное.

2D пошла другим путем, внедрив компактный явный реестр precompile-контрактов. Каждый «контракт» представляет собой Elixir-модуль, который помещается в один файл и читается от начала до конца. Он реализует фиксированный интерфейс (behaviour) и располагается по фиксированному адресу в пространстве имен 0x2D00…. Здесь нет интерпретатора байт-кода и нет слоя учета газа, в котором можно допустить ошибку. Набор исполняемого кода закрыт: precompile-контракты развертывает сам оператор узла, поэтому всегда известно, что именно будет выполняться.

В этой статье мы последовательно разберем: как транзакция находит нужный precompile, что требует интерфейс @behaviour Chain.Precompile от реализации, где находится реестр, как выглядит рабочий HTLC precompile и почему эта модель строже и безопаснее wrapped-моста.

У каждой транзакции есть адрес назначения (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-перевод: списать с отправителя, зачислить получателю, взять комиссию.
...
end
end

Две ветки, одно решение. Зарегистрирован? Вызываем обработчик. Не зарегистрирован? Переводим USDC. Никакой другой произвольный код не выполняется; вся кастомная логика проходит исключительно через 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()}
end
  • address/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
end
end
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 по адресу 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-код спецификации», а это задача, которую формальные методы позволяют решать в промышленном масштабе.

Модель 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-контрактов, где корректность определяется протоколом, а не отдельной функцией.