Мост: 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
Заголовок раздела «Почему не lock-mint»Стандартная архитектура моста — 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). Функцию
lockcross-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.
Refill-mint и инвариант эмиссии
Заголовок раздела «Refill-mint и инвариант эмиссии»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 выполняет три шага:
- Отклоняет вызов, если caller не равен
bridge_operator_address. Аккаунт оператора ограничен только precompile-вызовами; обычные переводы с адреса оператора блокируются block executor-ом. - Вычисляет
eth_event_id = keccak256(eth_chain_id ‖ eth_tx_hash ‖ eth_log_index)и пробует вставить строку в ledgerbridge_mintsс этим id. Primary key гарантирует, что одна и та же тройка не может привести к повторному минту. Дляbridge_lockрядом с event id сохраняетсяhtlc_hash, связывая минт с конкретным HTLC-свопом. - Если вставка прошла, кредитует
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, ...}} endendКаждая строка порождает один JSON-RPC запрос к Ethereum:
eth_getTransactionReceipt(tx_hash)— из ответа берётся лог под индексомlog_index.eth_getBlockByNumber("finalized")— номер последнего finalized блока; блок receipt-а должен быть не позже.
Для строк, созданных через bridge_lock (с htlc_hash), верификатор дополнительно извлекает receiverOn2D из topics[3] Ethereum-события Locked и сравнивает с получателем в htlc_swaps. Это предотвращает перенаправление средств на адрес атакующего даже при компрометации оператора.
Верификатор отклоняет строку, если хоть одно условие не выполняется:
| Причина | Что ловит |
|---|---|
:not_found | Receipt или лог не существует на Ethereum. |
:wrong_contract | Адрес лога не совпадает с Ethereum HTLC-контрактом. |
:wrong_event_signature | topic[0] лога не совпадает с сигнатурой Locked. |
:chain_id_mismatch | Chain id RPC не совпадает с eth_chain_id из строки. |
:amount_mismatch | Amount в data лога не совпадает с заявленным. |
:not_finalized | Блок существует, но до finality ещё не дошёл. |
:receiver_mismatch | receiverOn2D в 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, не дойдёт до честных пользователей: каждый честный верификатор отклонит блок.
Helios: что стоит за «Ethereum RPC»
Заголовок раздела «Helios: что стоит за «Ethereum RPC»»Верификатор не доверяет 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 / bridge-out
Заголовок раздела «Сценарий bridge-in / bridge-out»Bridge-in (Ethereum → 2D):
- Пользователь отправляет USDC в lock на Ethereum. Alice вызывает
lock(hash, receiverOn2D, amount, deadline)на Ethereum HTLC-контракте (BridgeHTLC.sol);amountUSDC уходят в escrow подhash. ПараметрreceiverOn2D— это адрес Alice на 2D, он попадает в indexed topic событияLocked, чтобы верификатор мог его проверить. - Оператор ждёт finality. Оркестратор отслеживает событие
Locked, опрашиваетeth_getBlockByNumber("finalized")и ждёт, пока блок с lock-ом не окажется finalized. Это занимает примерно 12-15 минут на Ethereum mainnet. - Оператор пополняет пул и лочит на 2D (атомарно). Оператор отправляет
bridge_lock(chain_id, tx_hash, log_index, amount, hash, Alice, deadline)на0x2D00…0003. Precompile вставляет строку вbridge_mints, кредитуетamountUSD-stable на счёт оператора и сразу лочит их в HTLC для Alice под тем жеhash. Верификатор независимо перепроверяет Ethereum-событие и проверяет, чтоreceiverOn2Dиз Ethereum-лога совпадает с адресом Alice в HTLC-свопе; при успехе блок принимается, при провале — отклоняется. - Alice забирает USD-stable на 2D. Её кошелёк вызывает
claim(preimage)на 2D HTLC по адресу0x2D00…0001. Посколькуsha256(preimage) = hashи deadline не прошёл, HTLC кредитуетamountUSD-stable на счёт Alice. - Оператор забирает 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. Вывод задерживается, но не теряется.
Сводка trust-модели
Заголовок раздела «Сводка trust-модели»| Угроза | Результат |
|---|---|
| Компрометация ключа оператора | Нельзя подделать 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_mint | Precompile проверяет только 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 в архитектуре цепи
Заголовок раздела «Где bridge в архитектуре цепи»Мост собран из трёх компонентов, описанных отдельно. Поблочная проверка верификатора распространяется на bridge_mints через cross-chain hook, описанный выше. State root фиксирует dedup-инвариант bridge_mints: злонамеренный producer не может дважды провести минт, не сломав хеш цепи. HTLC-примитив, через который идёт settlement, работает как precompile; мост — это протокол поверх этого примитива, а не контракт в виртуальной машине.