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

Мост: HTLC settlement и per-event refill

Мосты — крупнейший класс краж в крипте. С 2020 года из cross-chain мостов украдено больше $2.8B, порядка 40% всех Web3-потерь (Chainalysis / industry summary). Только за первые четыре месяца 2026 года мосты потеряли ещё более $750M (Phemex DeFi hacks 2026). Схема одинаковая: custody-контракт хранит залоченные токены на цепочке A, федерация валидаторов подтверждает lock и подписывает unlock или mint на цепочке B. Достаточно скомпрометировать подпись — весь пул уходит.

Мост 2D построен так, чтобы эта схема не повторилась. Функции unlock() не существует нигде; settlement работает через preimage-locked HTLC на обеих сторонах. Предварительного выпуска токенов нет; эмиссия на стороне 2D начинается с нуля и растёт по одному event-у, каждый из которых верификатор независимо перепроверяет по finalized Ethereum-стейту. Оператор — только координатор: залочить на одной стороне, залочить на другой, передать пользователю preimage.

Статья разбирает: почему HTLC, а не lock-mint; как работает refill-mint (эмиссия привязана к Ethereum-событиям 1:1); что именно верификатор перепроверяет; и какая trust-модель получается.

Стандартная архитектура моста — lock-mint. Alice отправляет USDC в custody-контракт на цепочке A. Федерация валидаторов видит lock-event и подписывает вызов mint(Alice, amount) на wrapped-token контракте цепочки B. Wrapped-токены свободно ходят; потом кто-то делает redeem, мост выполняет симметричный burn, и unlock() на цепочке A высвобождает исходные USDC.

Структурная проблема: финальный unlock() безусловный с точки зрения цепочки. Любой обладатель нужных ключей (порог валидаторов, multisig, кворум oracle-а) может вызвать unlock() на любую сумму вплоть до TVL моста. Компрометация ключей = весь пул уходит.

Катастрофические провалы — это история компрометаций unlock-полномочий:

  • Wormhole (2022, ~$320M). Из-за ошибки в Solana-хелпере прошла поддельная guardian-подпись, и wETH появились из воздуха.
  • Ronin (2022, ~$620M). Пять из девяти ключей валидаторов украдены через фишинг; атакующий подтвердил два крупных вывода.
  • Nomad (2022, ~$190M). Апгрейд случайно отключил проверку, и unlock стал доступен всем желающим.
  • Poly Network (2021, ~$611M). Функцию lock cross-chain менеджера можно было обманом заставить вызвать unlock на произвольные суммы.

Разные уязвимости, один примитив: кто-то с ключами вызывает unlock.

2D заменяет lock-mint на HTLC settlement на обеих сторонах. Alice отправляет USDC в Ethereum HTLC-контракт под хешем H и deadline-ом. Оператор отправляет эквивалент USD-stable в 2D HTLC под тем же хешем. Alice раскрывает preimage P (sha256(P) = H) и забирает USD-stable на 2D. Оператор видит P на 2D-цепи и забирает исходные USDC на Ethereum.

Полномочий unlock больше нет. Единственная функция, которая высвобождает средства на любой из сторон, — это claim(preimage). Она работает только при sha256(preimage) = hash и только до deadline-а. В штатном режиме preimage-ы создают пользователи: пользователь выбирает preimage, публикует hash = sha256(preimage) и раскрывает preimage только при claim. Однако скомпрометированный ключ оператора может создать новые HTLC lock-и с произвольными hash и receiver, а затем забрать средства через claim с соответствующим preimage. Ограничение переводов (оператор может вызывать только precompile, но не делать обычные переводы) сужает пути вывода, но не блокирует этот HTLC-опосредованный маршрут. Полный анализ exposure — в секции trust-модели ниже.

Парный refund(hash) возвращает средства отправителю, когда deadline проходит без claim-а. Худший сценарий для пользователя: refund срабатывает, деньги возвращаются. Не существует сценария, в котором атакующий уносит TVL.

HTLC-своп на стороне 2D требует от оператора ликвидности. Откуда она берётся?

Стандартный ответ wrapped-моста: «выпусти запас заранее и доверяй оператору, что не сбежит». 2D отказывается от этого доверия. В production на нулевой день в пуле оператора ноль USD-stable. Получить USD-stable можно, только подтвердив finalized Ethereum lock: каждый USD-stable на стороне 2D соответствует 1:1 проверенному Ethereum Locked-событию.

Механизм живёт на precompile BridgeRefillMint по адресу 0x2D00…0003 (lib/chain/precompiles/bridge_refill_mint.ex). Он предоставляет два селектора:

refill_mint(uint64 eth_chain_id, bytes32 eth_tx_hash, uint32 eth_log_index, uint256 amount)
bridge_lock(uint64 eth_chain_id, bytes32 eth_tx_hash, uint32 eth_log_index,
uint256 amount, bytes32 htlc_hash, address receiver, uint256 deadline_ms)

refill_mint обрабатывает простой случай: оператор ссылается на finalized Ethereum Locked-событие и пополняет пул. bridge_lock делает то же пополнение и атомарно создаёт HTLC lock на стороне 2D за один вызов, привязывая Ethereum-событие к конкретному получателю на 2D. Атомарный вариант убирает окно между refill и lock, когда средства оператора лежат без привязки.

В calldata — тройка-источник, однозначно идентифицирующая одно Locked-событие на Ethereum, плюс заявленный amount. Precompile выполняет три шага:

  1. Отклоняет вызов, если caller не равен bridge_operator_address. Аккаунт оператора ограничен только precompile-вызовами; обычные переводы с адреса оператора блокируются block executor-ом.
  2. Вычисляет eth_event_id = keccak256(eth_chain_id ‖ eth_tx_hash ‖ eth_log_index) и пробует вставить строку в ledger bridge_mints с этим id. Primary key гарантирует, что одна и та же тройка не может привести к повторному минту. Для bridge_lock рядом с event id сохраняется htlc_hash, связывая минт с конкретным HTLC-свопом.
  3. Если вставка прошла, кредитует amount на счёт оператора и генерирует BridgeRefillMinted(eth_event_id, operator, amount). Для bridge_lock кредитованные средства сразу уходят в HTLC lock для указанного receiver, а рядом с refill-событием генерируется BridgeLocked.

Никакого батчинга. Один вызов на каждое finalized Locked-событие. На бесплатных транзакциях 2D нет смысла экономить на вызовах; по одному на event — и инвариант эмиссии остаётся жёстким на каждом блоке.

Форма calldata выбрана намеренно. Ранний дизайн передавал только производный eth_event_id как bytes32. Но верификатору нужна исходная тройка (chain_id, tx_hash, log_index), чтобы запросить Ethereum, а keccak256 необратим. Хранение тройки рядом с производным id делает верификатор самодостаточным: всё, что ему нужно для повторной проверки минта, лежит в самом блоке.

Авторизация на BridgeRefillMint — одна проверка: caller равен оператору. Этого хватает, чтобы случайные адреса не могли минтить, но недостаточно, чтобы гарантировать, что указанное событие реально существует. Скомпрометированный ключ оператора может вызвать refill_mint с выдуманной тройкой и фиктивным amount; precompile послушно вставит строку и кредитует пул.

Здесь вступает верификатор. После того как producer выполнил кандидатный блок, но до того как верификатор его принял, каждая новая строка bridge_mints проходит независимую cross-chain проверку (lib/chain/verifier/cross_chain_check.ex):

def verify_block_refills(block_number) do
block_number
|> load_rows()
|> Enum.reduce_while(:ok, &verify_row/2)
end
defp verify_row(row, :ok) do
case EthereumRpc.verify_locked_event(
row.eth_chain_id, row.eth_tx_hash,
row.eth_log_index, Decimal.to_integer(row.amount)
) do
{:ok, :verified, receiver_on_2d} ->
verify_receiver(row, receiver_on_2d)
{:error, reason} ->
{:halt, {:error, :unbacked_refill_mint, ...}}
end
end

Каждая строка порождает один JSON-RPC запрос к Ethereum:

  • eth_getTransactionReceipt(tx_hash) — из ответа берётся лог под индексом log_index.
  • eth_getBlockByNumber("finalized") — номер последнего finalized блока; блок receipt-а должен быть не позже.

Для строк, созданных через bridge_lockhtlc_hash), верификатор дополнительно извлекает receiverOn2D из topics[3] Ethereum-события Locked и сравнивает с получателем в htlc_swaps. Это предотвращает перенаправление средств на адрес атакующего даже при компрометации оператора.

Верификатор отклоняет строку, если хоть одно условие не выполняется:

ПричинаЧто ловит
:not_foundReceipt или лог не существует на Ethereum.
:wrong_contractАдрес лога не совпадает с Ethereum HTLC-контрактом.
:wrong_event_signaturetopic[0] лога не совпадает с сигнатурой Locked.
:chain_id_mismatchChain id RPC не совпадает с eth_chain_id из строки.
:amount_mismatchAmount в data лога не совпадает с заявленным.
:not_finalizedБлок существует, но до finality ещё не дошёл.
:receiver_mismatchreceiverOn2D в Ethereum-событии не совпадает с получателем HTLC на 2D.
:receiver_not_in_eventСтрока bridge_lock есть, но в Ethereum-событии нет receiverOn2D topic.
:bridge_lock_htlc_missingСтрока bridge_lock ссылается на htlc_hash, для которого нет HTLC-свопа.
:rpc_unreachable / :rpc_http_error / :malformed_responseОбороняемся от сбоев: трактуем как провал, не как успех.

Провал на любой строке отменяет блок с ошибкой :unbacked_refill_mint. Верификатор откатывает транзакцию (внешних побочных эффектов нет, cross-chain RPC только на чтение), отказывается принимать блок и помечает producer как источник consensus violation.

Порядок выполнения принципиален. Проверка запускается после BlockExecutor.execute_transactions (чтобы новые строки bridge_mints были видны внутри той же SERIALIZABLE-транзакции) и до Chain.StateRoot.compute. Producer-у доверяем в момент включения транзакции в блок; финальный авторитет — за верификатором. Скомпрометированный producer, который включил необеспеченный refill, не дойдёт до честных пользователей: каждый честный верификатор отклонит блок.

Верификатор не доверяет Infura. eth_getTransactionReceipt и eth_getBlockByNumber с удалённого RPC — это просто HTTP-ответ: он может содержать что угодно. Мост, который доверяет стороннему RPC для определения finality, по сути передаёт свою безопасность владельцу endpoint-а.

Рекомендуемая production-конфигурация направляет JSON-RPC URL на локальный сайдкар helios. Helios — light client для Ethereum: он отслеживает sync committee beacon-цепи, криптографически проверяет header-ы и отдаёт eth_* JSON-RPC API, за которым стоят данные, проверенные light-client-ом. При использовании helios trust-предположение сводится к «≥ 1/3 beacon sync committee честны» — тот же порог, что обеспечивает finality самого Ethereum.

Важная оговорка: helios — это операционное решение, а не инвариант протокола. Код читает ETHEREUM_RPC_URL из окружения и ходит по HTTP на указанный адрес. Ничто не мешает указать Infura, Alchemy или произвольный прокси. Если развёртывание не использует helios, cross-chain безопасность моста деградирует до «доверяем тому, кто стоит за endpoint-ом».

В коде зависимость оформлена как behaviour с двумя реализациями (lib/chain/verifier/ethereum_rpc.ex):

defmodule Chain.Verifier.EthereumRpc do
@callback verify_locked_event(
chain_id :: pos_integer(),
tx_hash :: <<_::256>>,
log_index :: non_neg_integer(),
expected_amount :: pos_integer()
) :: {:ok, :verified, receiver_on_2d :: binary() | nil}
| {:error, error_reason()}
end

В возвращаемом значении теперь передаётся адрес receiverOn2D, извлечённый из topics[3] Ethereum-события Locked (или nil, если событие имеет меньше четырёх indexed параметров). Cross-chain проверка использует его для верификации того, что bridge_lock направляет средства правильному получателю на 2D.

Chain.Verifier.EthereumRpc.HTTP делает реальные JSON-RPC вызовы по :chain, :ethereum_rpc_url, который в production указывает на helios-процесс на том же хосте. Chain.Verifier.EthereumRpc.Stub возвращает настраиваемый ответ для тестов. Выбор реализации идёт через :chain, :ethereum_rpc_module и действует fail-closed: compile-time default-а нет. Если приложение стартует без ETHEREUM_RPC_URL в production или без явной конфигурации Stub в тестах, верификатор падает с описательным сообщением, а не молча принимает любые refill.

Bridge-in (Ethereum → 2D):

  1. Пользователь отправляет USDC в lock на Ethereum. Alice вызывает lock(hash, receiverOn2D, amount, deadline) на Ethereum HTLC-контракте (BridgeHTLC.sol); amount USDC уходят в escrow под hash. Параметр receiverOn2D — это адрес Alice на 2D, он попадает в indexed topic события Locked, чтобы верификатор мог его проверить.
  2. Оператор ждёт finality. Оркестратор отслеживает событие Locked, опрашивает eth_getBlockByNumber("finalized") и ждёт, пока блок с lock-ом не окажется finalized. Это занимает примерно 12-15 минут на Ethereum mainnet.
  3. Оператор пополняет пул и лочит на 2D (атомарно). Оператор отправляет bridge_lock(chain_id, tx_hash, log_index, amount, hash, Alice, deadline) на 0x2D00…0003. Precompile вставляет строку в bridge_mints, кредитует amount USD-stable на счёт оператора и сразу лочит их в HTLC для Alice под тем же hash. Верификатор независимо перепроверяет Ethereum-событие и проверяет, что receiverOn2D из Ethereum-лога совпадает с адресом Alice в HTLC-свопе; при успехе блок принимается, при провале — отклоняется.
  4. Alice забирает USD-stable на 2D. Её кошелёк вызывает claim(preimage) на 2D HTLC по адресу 0x2D00…0001. Поскольку sha256(preimage) = hash и deadline не прошёл, HTLC кредитует amount USD-stable на счёт Alice.
  5. Оператор забирает USDC на Ethereum. Preimage теперь виден на 2D-цепи (в calldata claim-транзакции и в логе HTLC_Claimed). Оператор вызывает claim(preimage) на Ethereum HTLC и возвращает исходные USDC в свой пул.

Bridge-out (2D → Ethereum) симметричен: та же роль у оператора, тот же settlement через preimage на обеих сторонах. Накопленные USDC финансируют bridge-out выплаты; если USDC у оператора на Ethereum закончились, выводы становятся в очередь, пока не придут новые поступления. DoS-вектор не превращается в drain. Вывод задерживается, но не теряется.

УгрозаРезультат
Компрометация ключа оператораНельзя подделать backed refill (верификатор отклонит фиктивное событие). Нельзя обойти HTLC claim/refund (settlement через preimage). Нельзя тронуть USD-stable, уже залоченные в HTLC-свопах. Нельзя делать обычные переводы: block executor блокирует non-precompile переводы с адреса оператора. Можно вызывать precompile-функции: refill_mint против реальных ожидающих Ethereum-локов (кредитует баланс оператора без привязки к получателю), bridge_lock (кредитует и сразу лочит для конкретного получателя), а также HTLC lock/claim для перемещения существующего баланса через swap-потоки. Когда bridge-in идёт исключительно через bridge_lock, атомарный refill+lock убирает окно с незалоченным float. Когда используется refill_mint, баланс оператора между refill и lock остаётся уязвимым. Exposure = текущий баланс оператора + Ethereum-локи, доступные для refill_mint, но ещё не обработанные. HTLC убирает класс unlock()-атак; ограничение переводов сужает attack surface, но не устраняет все precompile-опосредованные пути.
Оператор отправляет фиктивный refill_mintPrecompile проверяет только caller == operator и вставляет строку. Cross-chain проверка срабатывает позже, на стороне верификатора, при replay блока. Честный верификатор отклоняет блок, если Ethereum-событие не найдено, не совпадает контракт/сигнатура или блок не finalized. Защита работает на уровне верификатора, не на уровне producer-а.
Скомпрометированный producer включает необеспеченный refillТот же путь. Каждый честный верификатор независимо отклоняет блок. До пользователей, подключённых через честного верификатора, он не доходит.
Пользователь не успел сделать claim до deadline-аПотеря ограничена одним свопом. refund(hash) возвращает средства отправителю после deadline-а.
Helios-сайдкар скомпрометированЕсли helios развёрнут: эквивалентно ≥ 2/3 beacon sync committee злонамеренны. Если helios не развёрнут и ETHEREUM_RPC_URL указывает на сторонний endpoint, cross-chain безопасность деградирует до доверия оператору этого endpoint-а. Helios — операционная рекомендация, не протокольный инвариант.
Один и тот же event отправлен дваждыОтклоняется на стороне цепи. PK bridge_mints на eth_event_id включён в state root; producer, который обошёл бы PK, был бы пойман при пересчёте state root верификатором.

Мост убирает unlock()-полномочия, которые позволяли сливать wrapped-мосты, и привязывает каждый минт к Ethereum-событию, проверенному верификатором. Ограничение переводов оператора блокирует обычные переводы с адреса оператора, но оператор по-прежнему может двигать средства через precompile-вызовы (HTLC lock/claim, refill_mint). Когда bridge-in идёт через bridge_lock, refill и HTLC lock происходят атомарно, без окна с незалоченным float. Когда используется refill_mint, баланс оператора между refill и последующим HTLC lock остаётся уязвимым. Средства, уже залоченные в HTLC-свопах, защищены: их может вывести только claim(preimage) или refund(hash).

Мост собран из трёх компонентов, описанных отдельно. Поблочная проверка верификатора распространяется на bridge_mints через cross-chain hook, описанный выше. State root фиксирует dedup-инвариант bridge_mints: злонамеренный producer не может дважды провести минт, не сломав хеш цепи. HTLC-примитив, через который идёт settlement, работает как precompile; мост — это протокол поверх этого примитива, а не контракт в виртуальной машине.