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

Мост: HTLC-расчёт и атомарный bridge-lock

Статус production-сети 77: вывод из POA в Ethereum выключен. Описанный ниже LP bridge-out — реализация за feature gate, а не доступный production-путь вывода. Не рассчитывайте на POST /api/v1/bridge/out/quote и не блокируйте средства для этого сценария до отдельного проверенного включения. Депозиты Ethereum → POA — отдельный путь.

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

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

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

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

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

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

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

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

2D заменяет lock-mint на HTLC-расчёт на обеих сторонах. Alice отправляет USDC в Ethereum HTLC-контракт, блокируя их хешем H и дедлайном. Оператор отправляет эквивалент USD-stable в 2D HTLC под тем же хешем. Alice раскрывает preimage P (где sha256(P) = H) и забирает USD-stable в сети 2D. После этого preimage становится публичным в 2D, и любой наблюдатель может использовать его для финализации заявки (claim) в Ethereum; USDC выплачиваются на адрес claimer, указанный при блокировке.

Привилегированных полномочий на unlock больше не существует. Средства могут быть перемещены только через claim(preimage), который срабатывает только при условии sha256(preimage) = hash, только до истечения дедлайна и всегда выплачивает средства сконфигурированному claimer-у. Альтернативный путь — refund(hash), который возвращает средства исходному отправителю после дедлайна, если claim так и не произошел. Preimage создают сами пользователи: пользователь выбирает preimage, публикует hash = sha256(preimage) и раскрывает preimage только при claim. Cross-chain проверки верификатора (описанные ниже) защищают от того, чтобы скомпрометированный 2D-ключ оператора перенаправил легитимные пользовательские блокировки на адрес атакующего. Привязка claimer-а к списку разрешенных адресов оператора связывает claimer из цитируемого события Locked с конфигурацией верификатора. Это перекрывает путь, при котором атакующий мог бы профинансировать собственную блокировку в Ethereum с выбранным им claimer и выпустить необеспеченные токены.

Худший сценарий для пользователя, чьи средства заблокированы под выбранный им preimage: срабатывает refund, и деньги возвращаются туда, откуда пришли. Переместить эти средства не может никто, кроме самого пользователя, независимо от состояния оператора.

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

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

Этот механизм реализован в precompile-контракте BridgeRefillMint по адресу 0x2D00…0003. Он предоставляет единственный селектор, изменяющий состояние:

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

Функция bridge_lock атомарно выпускает токены и создает HTLC-блокировку на стороне 2D за один вызов, привязывая Ethereum-событие к конкретному получателю. Отдельного пути для пополнения (refill) не существует: каждая выпущенная единица сразу блокируется на конкретного получателя через HTLC, что полностью исключает наличие свободного баланса у оператора.

В calldata передается тройка-источник, однозначно идентифицирующая одно событие Locked в Ethereum, а также amount, HTLC hash, receiver, deadline и eth_observation_block (номер Ethereum-блока "finalized" в момент подписания, сохраняется в строке bridge_mints для replay-безопасной проверки claimerUsedHash). Precompile-контракт выполняет три шага:

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

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

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

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

Здесь вступает в дело верификатор. После того как производитель выполнил кандидатный блок, но до того как верификатор его принял, каждая новая строка bridge_mints проходит независимую межсетевую проверку. Каждая строка инициирует четыре JSON-RPC запроса к Ethereum:

  • eth_getTransactionReceipt(tx_hash): из ответа извлекается лог под индексом log_index. htlc_hash, sender и claimer берутся из индексированных топиков (topics[1], topics[2], topics[3]); receiverOn2D, amount и deadline декодируются из log.data (32-байтные слоты в указанном порядке).
  • eth_getBlockByNumber("finalized"): запрашивается номер последнего финализированного блока; блок квитанции (receipt) должен быть не позже этого номера.
  • eth_getLogs: выполняется поиск событий Refunded по hash и sender в ограниченном окне блоков после блокировки.
  • eth_call к claimerUsedHash(claimer, htlc_hash) на bridge-контракте: запрашивается storage-слот пары claimer/hash на eth_observation_block строки (номер финализированного блока Ethereum, зафиксированный в calldata bridge_lock в момент подписания). Проверка на receipt_block была бы бессмысленной, потому что в блоке квитанции этот слот ещё равен нулю; проверка на текущей finalized-голове ломала бы исторический replay после легитимного claim-а.

После подтверждения существования и финальности события Locked верификатор проводит пять несущих проверок привязок:

  1. Привязка HTLC hash. htlc_hash из bridge_mints должен совпадать с topics[1] Ethereum-события Locked. Без этого оператор мог бы сослаться на легитимное событие, но подставить свой hash, а затем вывести средства через deadline refund.
  2. Привязка получателя. Адрес receiverOn2D, декодированный из log.data[0:32] Ethereum-события, должен совпадать с получателем в htlc_swaps. Это предотвращает перенаправление средств на адрес атакующего.
  3. Привязка claimer-а к списку разрешенных адресов. claimer, декодированный из topics[3], должен входить в сконфигурированный allowlist операторов. Без этого атакующий с компрометированным 2D-ключом оператора мог бы профинансировать собственную Ethereum-блокировку с выбранным им claimer, пройти проверки внутренней согласованности (так как атакующий сам выбрал все значения) и выпустить необеспеченную эмиссию под собственные возвратные USDC. Allowlist — это первая протокольная граница, ограничивающая актора на стороне Ethereum доверенным набором; в самом Ethereum функция lock() остается permissionless. Отсутствующий или неразбираемый claimer трактуется как не входящий в allowlist, то есть отказ по закрытой модели до проверки членства.
  4. Проверка refund. Верификатор запрашивает eth_getLogs на наличие событий Refunded, соответствующих HTLC hash и отправителю, в ограниченном окне блоков после блокировки. Если Ethereum-лок был возвращен (refunded) до того, как оператор отправил bridge_lock, выпуск отклоняется.
  5. Storage-проверка claimer/hash. Верификатор вызывает claimerUsedHash(claimer, htlc_hash) на eth_observation_block из строки bridge_mints (консенсусное состояние, входит в state root), когда значение > 0. Оператор записывает текущий Ethereum-блок "finalized" при подписании bridge_lock (обогащение Executor / IntentWatcher на честном пути); при replay используется зафиксированное значение. Верификатор также требует receipt_block < eth_observation_block <= finalized_block (observation == receipt_block отклоняется как бессмысленный обход). Строки с eth_observation_block == 0 — legacy backfill после миграции; Layer 6b для них пропускается. Ограничение: скомпрометированный оператор с raw calldata может минимизировать eth_observation_block выше receipt_block; консенсус не доказывает равенство встроенного значения finality при подписании — signing policy и операционные контроли остаются несущими.

Каждая строка bridge_mints обязана иметь htlc_hash (fail-closed). Строка без него отклоняется с ошибкой :missing_htlc_hash.

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

ПричинаЧто предотвращает
:not_foundКвитанция или лог не существует в Ethereum.
:wrong_contractАдрес лога не совпадает с Ethereum HTLC-контрактом.
:wrong_event_signaturetopic[0] лога не совпадает с сигнатурой Locked.
:chain_id_mismatchChain id RPC не совпадает с eth_chain_id из строки.
:amount_mismatchAmount в data лога не совпадает с заявленным.
:not_finalizedБлок существует, но еще не достиг finality.
:htlc_hash_mismatchhtlc_hash в 2D не совпадает с topics[1] Ethereum-события.
:receiver_mismatchreceiverOn2D в Ethereum-событии не совпадает с получателем HTLC в 2D.
:ethereum_lock_refundedНайдено событие Refunded для этой блокировки в Ethereum.
:claimer_not_in_allowlistclaimer цитируемого Locked-события не входит в сконфигурированный operator allowlist (или отсутствует/неразбираем).
:hash_already_used_by_claimerclaimerUsedHash(claimer, hash) ненулевой на eth_observation_block (USDC уже забраны в Ethereum до подписания mint).
:eth_observation_block_missingНекорректный eth_observation_block (не legacy-сентинел 0).
:eth_observation_block_at_receiptНаблюдение равно receipt_block (бессмысленный обход).
:eth_observation_block_before_receiptНаблюдение раньше блока квитанции Locked.
:eth_observation_block_after_finalizedНаблюдение выше финализированной головы Ethereum при верификации.
:missing_htlc_hashУ строки bridge_mints нет htlc_hash (автономный refill запрещен).
:rpc_unreachable / :rpc_http_errorТранспортные ошибки; трактуются как провал верификации.

Провал на любой строке отменяет весь блок. Верификатор откатывает транзакцию (внешних побочных эффектов нет, cross-chain RPC работает только на чтение), отказывается принимать блок и помечает производителя блоков как источник нарушения консенсуса (consensus violation). Транспортные ошибки (:rpc_unreachable, :rpc_http_error) вызывают повтор с экспоненциальной задержкой (exponential backoff); все остальные ошибки трактуются как нарушение консенсуса и приводят к немедленному исключению (raise).

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

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

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

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

В коде эта зависимость оформлена как behaviour с двумя реализациями:

defmodule Chain.Verifier.EthereumRpc do
@callback verify_locked_event(chain_id, tx_hash, log_index, expected_amount)
:: {:ok, :verified, verified_result()} | {:error, error_reason()}
@callback check_not_refunded(htlc_hash, sender, receipt_block)
:: :ok | {:error, error_reason()}
@callback check_hash_not_used(htlc_hash, claimer, block_number)
:: :ok | {:error, error_reason()}
end

verify_locked_event возвращает map с полями htlc_hash_on_eth (из topics[1]), sender_on_eth (из topics[2]), claimer_on_eth (из topics[3]), receiver_on_2d (из log.data[0:32]), receipt_block и finalized_block (текущая финализированная голова Ethereum на момент верификации). Модуль CrossChainCheck использует их вместе с eth_observation_block строки для проверки HTLC hash, получателя, allowlist claimer-а, refund и потребления hash на блоке наблюдения. check_not_refunded ищет Refunded в окне от receipt_block. check_hash_not_used делает eth_call к claimerUsedHash на row.eth_observation_block. Allowlist claimer-а — pure-Elixir проверка через Chain.Verifier.OperatorAllowlist, без RPC.

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

Bridge-in (Ethereum → 2D):

  1. Пользователь отправляет USDC в блокировку на Ethereum. Alice вызывает lock(hash, claimer, receiverOn2D, amount, deadline) в Ethereum HTLC-контракте (BridgeHTLC.sol), указав claimer равным Ethereum-адресу оператора моста (развернутый OperatorVault); amount USDC уходят в эскроу под hash. В событии Locked индексируются hash, sender и claimer; receiverOn2D, amount и deadline сохраняются в неиндексированном поле log.data. Верификатор проверяет все эти данные.
  2. Оператор ждет finality. Оркестратор отслеживает событие Locked, опрашивает eth_getBlockByNumber("finalized") и ждет, пока блок с блокировкой не станет finalized. Это занимает примерно 12-15 минут в основной сети Ethereum.
  3. Оператор блокирует средства в 2D (атомарно). Оператор отправляет bridge_lock(chain_id, tx_hash, log_index, amount, hash, Alice, deadline, eth_observation_block) на 0x2D00…0003, где eth_observation_block — финализированная голова Ethereum в момент подписания. Precompile атомарно вставляет HTLC-своп и строку bridge_mints (включая зафиксированный блок наблюдения). Баланс оператора остается нулевым. Верификатор перепроверяет событие, htlc_hash, receiverOn2D, allowlist, refund и claimerUsedHash на eth_observation_block; при успехе блок принимается.
  4. Claim в 2D HTLC (permissionless). Любой, у кого есть preimage, вызывает claim(preimage) в 2D HTLC по адресу 0x2D00…0001. Зачисление всегда идёт на receiver блокировки (Alice), не на caller. Для quoted inflow preimage держит оператор до этого claim — dApp-котировка его не возвращает. После claim preimage есть в логе HTLC_Claimed на 2D.
  5. Claim в Ethereum (permissionless). Любой может отправить claim(sender, hash, preimage) в BridgeHTLC; USDC выплачиваются сконфигурированному claimer (для production bridge-in — operator vault). На сети 77 IntentWatcher не подписывает этот claim через NetHSM (BRIDGE_NETHSM_SIGNING_ENABLED=false). После receipt 2D mint watcher отправляет claim с gas EOA оператора (BRIDGE_ETH_HTLC_CLAIM_KEY_FILE). Чтобы остановить auto-claim, снимите key file (или выключите watcher). Застрявшие gas-EOA транзакции заменяются по nonce/gasPrice из eth_getTransactionByHash, с потолком SignerPolicy hard max. Исключительное восстановление (RPC недоступен) — через mix bridge.record_eth_claim по operator runbook.

Выключено в production: реализация bridge-out (POA → Ethereum) за feature gate использует атомарный своп с поставщиком ликвидности (LP). Пользователь генерирует preimage, запрашивает котировку через POST /api/v1/bridge/out/quote, затем блокирует 2D USD-stable в 2D HTLC по адресу 0x2D00…0001, указав LP в качестве получателя. LP watcher сверяет блокировку с котировкой и блокирует Ethereum USDC из отдельного LP-кошелька через BridgeHTLC.lock(hash, claimer=userEth, receiverOn2D=lp2D, amount, deadline). Пользователь забирает USDC в Ethereum и раскрывает preimage; LP с помощью того же preimage забирает средства из 2D HTLC. Если одна из сторон останавливает процесс, исходный отправитель выполняет refund после истечения дедлайна на своей стороне. Дедлайн в 2D настраивается длиннее дедлайна в Ethereum, чтобы LP успевал восстановить средства после Ethereum-claim-а пользователя.

OperatorVault, используемый для bridge-in, не финансирует пользовательский bridge-out и не может выступать отправителем (sender) в функции lock(). Функция OperatorVault.bridgeOut(...) зарезервирована исключительно для авторизованных governance-операций (например, управление казначейством или ребалансировка). Пилотный запуск работает по принципу fail-closed: новые котировки отключаются, когда сервис недоступен, ликвидность LP или лимиты исчерпаны, либо мониторинг показывает сбои; активные свопы всё равно доводятся до claim или refund. Стартовые настройки по умолчанию консервативны и конфигурируемы: 100 USDC на один своп, 1000 USDC скользящего 24-часового объема котировок и комиссия LP min(50 bps, 10 USDC).

bridge_lock получает 8-й аргумент ABI (eth_observation_block); compute_bridge_mints_root включает новую колонку. Считайте это hard fork для деплоев с историческими строками bridge_mints:

  • Рекомендуется (pilot / devnet): сброс БД (mix ecto.reset или новый genesis) до миграции 20260604130100.
  • In-place: существующие строки получают eth_observation_block = 0 и пропускают Layer 6b при replay; новые mint обязаны иметь > 0 из precompile.
  • Ethereum RPC: верификаторам нужен archive-capable state для eth_call к claimerUsedHash на исторических блоках наблюдения.

Оператор моста — это операционно один участник, но криптографически он использует два разных ключа: ключ стороны 2D, подписывающий precompile-вызовы bridge_lock (исполнитель блоков отклоняет все остальные типы транзакций с этого адреса), и ключ подписи стороны Ethereum, подписывающий только bridgeOut(...) в развернутом смарт-контракте OperatorVault. Ethereum-side claim(sender, hash, preimage) является permissionless: любой наблюдатель может отправить раскрытый preimage, а vault-wrapper сначала проверяет, что claimer блокировки — это сам vault, и только потом перенаправляет вызов в BridgeHTLC. Управление vault-ом (allowlist, лимиты, ротация ключа подписи, обновления) принадлежит отдельному governance-принципалу; у операционного ключа подписи governance-полномочий нет. Оба операционных ключа хранятся у разных подписантов, и каждый подписант дополнительно применяет vendor-side allowlist policy как эшелонированную защиту поверх on-chain ограничений: 2D-side signer отказывается подписывать что-либо, кроме селектора bridge_lock(...) к bridge precompile; Ethereum-side signer отказывается подписывать что-либо, кроме bridgeOut(address,uint256) к сконфигурированному OperatorVault. Прямые ERC-20 переводы из пула, lock(), refund(), любые вызовы к другим контрактам отклоняются on-chain самим vault-ом и на уровне policy layer еще до того, как HSM получает запрос на подпись. Компрометация каждого ключа рассматривается ниже как независимое событие.

УгрозаРезультат
Компрометация 2D-side ключа оператораКомпрометация только ключа стороны 2D не приводит к необеспеченной эмиссии: привязка claimer-а к allowlist-у верификатора отклоняет любое цитируемое событие Locked, чей claimer не находится в сконфигурированном operator allowlist. В Ethereum функция BridgeHTLC.lock() остается permissionless, но верификатор ограничивает цитируемого claimer-а: атакующий, самофинансирующий Ethereum-lock с выбранным им claimer-ом, отклоняется на этой привязке до коммита любого выпуска на стороне 2D. Исполнитель блоков также блокирует обычные переводы с 2D-адреса оператора; заблокированные пользователями USDC под выбранные ими preimage не затрагиваются; накопленный USDC-пул оператора не затрагивается. Остаточный риск возникает только при блокировке, которая ссылается на claimer из allowlist (например, развернутый vault); этот случай разобран в строке «Совместная компрометация» ниже.
Компрометация Ethereum-side ключа подписиEthereum-side ключ подписи — это операционный ключ для bridgeOut(destination, amount) на развернутом OperatorVault. Vault хранит накопленный USDC-пул. Ключ подписи не может переводить USDC произвольно: единственный привилегированный путь вывода — bridgeOut(...), ограниченный on-chain параметрами bridgeOutAllowlist, perTxCap и скользящим 24-часовым cumulativeCap. Ethereum-side claim(...) не является полномочием ключа подписи; любой может отправить валидный preimage, а выплата идет claimer-у, указанному при блокировке. Компрометация только ключа подписи позволяет вывести максимум cumulativeCap за 24 часа и только на адреса из allowlist; остальная часть пула остается под контролем governance. Заблокированные пользователями USDC в ожидающих bridge-in транзакциях не затрагиваются: их preimage выбирали пользователи, у атакующего их нет. 2D-эмиссия не затрагивается: для выпуска нужен 2D-side ключ. Эшелонированная защита: signing-service policy перед ключом подписи дополнительно отказывает во всем, кроме bridgeOut(...) к vault-у.
Совместная компрометация ключей подписи стороны 2D и стороны EthereumBridgeHTLC.lock() permissionless: атакующий с 2D-ключом может самофинансировать lock с claimer = vault и receiverOn2D = attacker. Allowlist проходит тривиально, 2D-ключ минтит N токенов. Layer 6b (claimerUsedHash на eth_observation_block) ловит уже claimed USDC до mint при честном embedding finality; не закрывает полностью злонамеренную минимизацию eth_observation_block в raw calldata. Атакующий делает claim в 2D, раскрывая preimage; любой watcher может вызвать claim в Ethereum до дедлайна, USDC попадают в vault. Для refund атакующему нужно не дать никому отправить preimage до дедлайна. Ключ подписи Ethereum не опустошает vault сверх cumulativeCap/24h на allowlist. Полный drain — компрометация governance.
Компрометация LP кошелька или сервисаLP не является оператором моста и не управляет выпуском токенов bridge-in. Скомпрометированный LP сервис может остановить выдачу котировок, выдать плохую котировку, не заблокировать средства в Ethereum или потерять выделенный LP-капитал. Он не может перемещать средства OperatorVault, выпускать 2D токены или забрать пользовательские средства, уже заблокированные в HTLC, без соответствующего preimage. Pilot caps и fail-closed quoting ограничивают подверженные риску запасы LP, пока путь маркет-мейкинга дорабатывается.
Компрометация governance vault-аВладелец vault-а (мультисиг с timelock-ом) контролирует setSigningKey, setBridgeOutAllowlist, setPerTxCap, setCumulativeCap и авторизует UUPS-обновления. Компрометация governance позволяет ротировать ключ подписи, добавить контролируемый атакующим адрес в allowlist, снять ограничения на лимиты или обновить контракт в обход всей политики — любого из этих действий достаточно для опустошения пула. Операционная защита: хранить governance за мультисигом с аппаратными ключами и окном timelock, достаточным для того, чтобы честные операторы успели обнаружить и отреагировать на злонамеренное изменение политики до его активации.
Оператор отправляет фиктивный bridge_lockPrecompile проверяет только caller == operator и вставляет строки. Межсетевая проверка срабатывает позже, на стороне верификатора, при повторном выполнении блока. Честный верификатор отклоняет блок, если Ethereum-событие не найдено, не совпадает контракт/сигнатура/hash или блок не финализирован. Защита работает на уровне верификатора, а не на уровне производителя.
Скомпрометированный производитель включает необеспеченный bridge_lockТот же механизм. Каждый честный верификатор независимо отклоняет блок. До пользователей, подключенных через честного верификатора, такой блок не доходит.
Пользователь не успел сделать claim до дедлайнаПотеря ограничена одним свопом. Функция refund(hash) возвращает средства отправителю после дедлайна.
Helios-сайдкар скомпрометированЕсли helios развернут: это эквивалентно тому, что ≥ 2/3 beacon sync committee являются злоумышленниками. Если helios не развернут и ETHEREUM_RPC_URL указывает на сторонний endpoint, cross-chain безопасность деградирует до доверия оператору этого endpoint-а. Helios — это операционная рекомендация, а не протокольный инвариант.
Одно и то же событие отправлено дваждыОтклоняется на стороне цепи. Первичный ключ bridge_mints по eth_event_id включен в state root; производитель, который попытался бы обойти первичный ключ, был бы пойман при пересчете state root верификатором.

Мост устраняет полномочия unlock(), которые позволяли опустошать wrapped-мосты. Каждый выпуск токенов атомарно привязан к HTLC конкретного получателя через bridge_lock, поэтому оператор никогда не держит незаблокированный баланс. Верификатор независимо подтверждает HTLC hash, получателя, allowlist claimer-а, статус refund и claimerUsedHash(claimer, htlc_hash) на eth_observation_block, зафиксированном в bridge_mints (в calldata bridge_lock при подписании), перед тем, как принять блок с выпуском. Средства, заблокированные в HTLC-свопах, защищены: их может вывести только claim(preimage) или refund(hash), а успешный claim всегда выплачивает средства сконфигурированному claimer-у.

В продакшене развернут OperatorVault.sol в качестве Ethereum-кошелька оператора. Vault — это UUPS-обновляемый смарт-контракт, разделяющий оператора на две роли с принудительным on-chain контролем.

Публичная финализация claim-а. Функцию claim(sender, hash, preimage) может вызвать любой наблюдатель. Vault сначала проверяет, что BridgeHTLC.lockClaimer(sender, hash) == address(this), а затем перенаправляет вызов в BridgeHTLC.claim(sender, hash, preimage). Разблокированные USDC накапливаются в vault-е; caller не может выбрать адрес выплаты.

Операционный ключ подписи. Не имеет governance-полномочий и возможности выполнять произвольный код. Ключ подписи авторизует только bridgeOut(destination, amount), который переводит USDC из vault-а на разрешенные адреса в пределах установленных лимитов. Этот путь используется только для авторизованных governance исходящих операций, например, для ребалансировки казначейства на мультисиг. Пользовательский bridge-out идет через описанный выше LP flow и не тратит средства vault-а. Вызов vault-а ограничен тремя on-chain лимитами: destination должен быть в bridgeOutAllowlist; amount — не больше perTxCap; точный скользящий 24-часовой кумулятивный отток по чекпоинтам с метками времени должен остаться не больше cumulativeCap после этого вызова.

Ключ подписи не может вызвать lock(), не может перевести USDC напрямую, не может обратиться ни к какому другому контракту. Компрометация только ключа подписи позволяет вывести максимум cumulativeCap за 24 часа и только на разрешенные адреса.

Управление политиками и обновлениями. Владелец контракта — мультисиг с timelock-ом — управляет всеми политиками (setSigningKey, setBridgeOutAllowlist, setPerTxCap, setCumulativeCap) и авторизует UUPS-обновления. Ключ подписи не может ротировать сам себя, изменить лимиты, отредактировать allowlist или обновить контракт.

Переключение моста на vault-claim контролируется claimer allowlist-ом 2D-верификатора. Третья привязка межсетевой проверки делает vault критически важным компонентом: если верификатор всё еще держит в allowlist-е только legacy-EOA оператора, Ethereum-блокировка с claimer = vault отклоняется на этапе проверки привязки 3. Блок производителя отбрасывается каждым честным верификатором, а заблокированные USDC возвращаются в Ethereum через refund(hash) после дедлайна. Соответственно, порядок развертывания следующий:

  1. Развернуть OperatorVault. Сконфигурировать governance, ключ подписи, perTxCap, cumulativeCap и bridgeOutAllowlist.
  2. Добавить адрес vault-а в OperatorAllowlist верификатора и распространить конфигурацию на каждого честного верификатора.
  3. Только после распространения шага 2 dApp-ы, оркестратор или любой intent-сервис могут начать публиковать Ethereum-блокировки с claimer = vault.

Нарушение порядка не приводит к потере средств, но каждая блокировка vault-claimer, созданная до распространения allowlist-а, будет проваливать bridge-in и ожидать refund.

Verifier OperatorAllowlist ведется в режиме append-only на время миграции. Добавление vault-а расширяет множество {legacy_EOA} до {legacy_EOA, vault}. Legacy-EOA не удаляется как часть нормальной миграции по следующим причинам:

  • Replay-детерминизм. Старые события Locked с claimer = legacy_EOA должны продолжать успешно верифицироваться при повторном выполнении (replay) исторической цепи на свежем узле. Удаление legacy-адреса сломало бы replay любого блока, ссылающегося на событие с legacy-claimer.
  • Окно перехода (Cutover overlap). Пока dApp-ы переключаются, в Ethereum сосуществуют блокировки и с claimer = legacy_EOA, и с claimer = vault. Обе остаются верифицируемыми в 2D в это окно.

Операционно оркестратор перестает ссылаться на legacy-EOA блокировки, как только vault становится активным (для новых событий вызывает bridge_lock только с vault-claimer), но legacy-адрес остается в allowlist-е постоянно. Удаление адреса — это отдельная процедура, требующая подтверждения, что ни один исторический блок на честных узлах не ссылается на этот адрес (обычно это безопасно только тогда, когда сегмент цепи, цитировавший адрес, ушел за горизонт практического replay-а).

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