Мост: 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
Заголовок раздела «Почему не lock-mint»Стандартная архитектура моста — это модель 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, и деньги возвращаются туда, откуда пришли. Переместить эти средства не может никто, кроме самого пользователя, независимо от состояния оператора.
Bridge-lock и инвариант эмиссии
Заголовок раздела «Bridge-lock и инвариант эмиссии»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-контракт выполняет три шага:
- Отклоняет вызов, если caller не равен
bridge_operator_address. Аккаунт оператора ограничен только вызовами precompile; обычные переводы с адреса оператора блокируются исполнителем блоков. - Вставляет строку HTLC-свопа (блокировка с привязкой к получателю) и строку
bridge_mints(обеспечение эмиссии) с ключомeth_event_id = keccak256(eth_chain_id ‖ eth_tx_hash ‖ eth_log_index). Первичный ключ гарантирует, что одна и та же тройка не может привести к повторному выпуску токенов. Рядом с event id сохраняетсяhtlc_hash, связывая выпуск с конкретным HTLC-свопом. - Если обе вставки прошли успешно, генерирует событие
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, зафиксированный в calldatabridge_lockв момент подписания). Проверка наreceipt_blockбыла бы бессмысленной, потому что в блоке квитанции этот слот ещё равен нулю; проверка на текущей finalized-голове ломала бы исторический replay после легитимного claim-а.
После подтверждения существования и финальности события Locked верификатор проводит пять несущих проверок привязок:
- Привязка HTLC hash.
htlc_hashизbridge_mintsдолжен совпадать сtopics[1]Ethereum-событияLocked. Без этого оператор мог бы сослаться на легитимное событие, но подставить свой hash, а затем вывести средства через deadline refund. - Привязка получателя. Адрес
receiverOn2D, декодированный изlog.data[0:32]Ethereum-события, должен совпадать с получателем вhtlc_swaps. Это предотвращает перенаправление средств на адрес атакующего. - Привязка claimer-а к списку разрешенных адресов.
claimer, декодированный изtopics[3], должен входить в сконфигурированный allowlist операторов. Без этого атакующий с компрометированным 2D-ключом оператора мог бы профинансировать собственную Ethereum-блокировку с выбранным имclaimer, пройти проверки внутренней согласованности (так как атакующий сам выбрал все значения) и выпустить необеспеченную эмиссию под собственные возвратные USDC. Allowlist — это первая протокольная граница, ограничивающая актора на стороне Ethereum доверенным набором; в самом Ethereum функцияlock()остается permissionless. Отсутствующий или неразбираемыйclaimerтрактуется как не входящий в allowlist, то есть отказ по закрытой модели до проверки членства. - Проверка refund. Верификатор запрашивает
eth_getLogsна наличие событийRefunded, соответствующих HTLC hash и отправителю, в ограниченном окне блоков после блокировки. Если Ethereum-лок был возвращен (refunded) до того, как оператор отправилbridge_lock, выпуск отклоняется. - 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_signature | topic[0] лога не совпадает с сигнатурой Locked. |
:chain_id_mismatch | Chain id RPC не совпадает с eth_chain_id из строки. |
:amount_mismatch | Amount в data лога не совпадает с заявленным. |
:not_finalized | Блок существует, но еще не достиг finality. |
:htlc_hash_mismatch | htlc_hash в 2D не совпадает с topics[1] Ethereum-события. |
:receiver_mismatch | receiverOn2D в Ethereum-событии не совпадает с получателем HTLC в 2D. |
:ethereum_lock_refunded | Найдено событие Refunded для этой блокировки в Ethereum. |
:claimer_not_in_allowlist | claimer цитируемого Locked-события не входит в сконфигурированный operator allowlist (или отсутствует/неразбираем). |
:hash_already_used_by_claimer | claimerUsedHash(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, который включил необеспеченный выпуск, не дойдет до честных пользователей: каждый честный верификатор отклонит такой блок.
Helios: что стоит за «Ethereum RPC»
Заголовок раздела «Helios: что стоит за «Ethereum RPC»»Верификатор не доверяет сервисам вроде 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()}endverify_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 / bridge-out
Заголовок раздела «Сценарий bridge-in / bridge-out»Bridge-in (Ethereum → 2D):
- Пользователь отправляет USDC в блокировку на Ethereum. Alice вызывает
lock(hash, claimer, receiverOn2D, amount, deadline)в Ethereum HTLC-контракте (BridgeHTLC.sol), указавclaimerравным Ethereum-адресу оператора моста (развернутыйOperatorVault);amountUSDC уходят в эскроу подhash. В событииLockedиндексируютсяhash,senderиclaimer;receiverOn2D,amountиdeadlineсохраняются в неиндексированном полеlog.data. Верификатор проверяет все эти данные. - Оператор ждет finality. Оркестратор отслеживает событие
Locked, опрашиваетeth_getBlockByNumber("finalized")и ждет, пока блок с блокировкой не станет finalized. Это занимает примерно 12-15 минут в основной сети Ethereum. - Оператор блокирует средства в 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; при успехе блок принимается. - Claim в 2D HTLC (permissionless). Любой, у кого есть preimage, вызывает
claim(preimage)в 2D HTLC по адресу0x2D00…0001. Зачисление всегда идёт наreceiverблокировки (Alice), не на caller. Для quoted inflow preimage держит оператор до этого claim — dApp-котировка его не возвращает. После claim preimage есть в логеHTLC_Claimedна 2D. - Claim в Ethereum (permissionless). Любой может отправить
claim(sender, hash, preimage)вBridgeHTLC; USDC выплачиваются сконфигурированномуclaimer(для production bridge-in — operator vault). На сети 77IntentWatcherне подписывает этот 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).
Обновление существующих цепей (hard fork)
Заголовок раздела «Обновление существующих цепей (hard fork)»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 и стороны Ethereum | BridgeHTLC.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_lock | Precompile проверяет только 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) после дедлайна. Соответственно, порядок развертывания следующий:
- Развернуть
OperatorVault. Сконфигурировать governance, ключ подписи,perTxCap,cumulativeCapиbridgeOutAllowlist. - Добавить адрес vault-а в
OperatorAllowlistверификатора и распространить конфигурацию на каждого честного верификатора. - Только после распространения шага 2 dApp-ы, оркестратор или любой intent-сервис могут начать публиковать Ethereum-блокировки с
claimer = vault.
Нарушение порядка не приводит к потере средств, но каждая блокировка vault-claimer, созданная до распространения allowlist-а, будет проваливать bridge-in и ожидать refund.
Неудаляемый allowlist
Заголовок раздела «Неудаляемый allowlist»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 в архитектуре цепи
Заголовок раздела «Где bridge в архитектуре цепи»Мост собран из трех компонентов, описанных отдельно. Поблочная проверка верификатора распространяется на bridge_mints через cross-chain hook, описанный выше. State root фиксирует инвариант дедупликации bridge_mints: злонамеренный производитель не может дважды провести выпуск токенов, не сломав хеш цепи. HTLC-примитив, через который идет расчет, работает как precompile; мост — это протокол поверх этого примитива, а не контракт в виртуальной машине.