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

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

В основной сети 77 вывод из POA в Ethereum выключен. Описанный ниже вывод через LP реализован за отдельным переключателем. Не используйте POST /api/v1/bridge/out/quote и не блокируйте средства для этого сценария до отдельно проверенного включения. Ввод Ethereum → POA работает независимо от него.

Мост связывает блокировку USDC в Ethereum с HTLC, закреплённым за получателем в POA. Верификатор проверяет исходное событие и привязки перед принятием блока. HTLC ограничивает условия расчёта; безопасность также зависит от реализации, доступности обеих сетей, подписантов и управления OperatorVault.

Блокировка содержит хеш секрета, получателя и срок. До истечения срока любой участник, знающий секрет, может вызвать claim; средства получает адрес, указанный в блокировке. После срока незавершённую блокировку можно закрыть через refund по правилам соответствующей сети.

В пользовательском HTLC секрет выбирает пользователь. При вводе по котировке (quoted inflow) секрет до исполнения хранит оператор; котировка не возвращает его приложению. Эти сценарии имеют разные границы доверия. Нельзя описывать весь ввод как процесс, в котором только пользователь контролирует раскрытие секрета.

При вводе через мост USDC после Ethereum-claim поступают в OperatorVault. Операционный ключ ограничен разрешёнными получателями и лимитами, а управление vault может менять политику и обновлять контракт. Поэтому отсутствие метода с именем unlock не устраняет риск компрометации управления или ошибки общего кода.

При вводе через мост каждое пополнение связано с финализированным событием Locked в Ethereum. Оператор не получает свободный баланс POA: запись моста сразу связывается с HTLC конкретного получателя.

Этот механизм реализован в прекомпайле 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 атомарно создаёт запись обеспечения bridge_mints и HTLC с заданным получателем. Баланс получателя увеличивается при успешном claim. При истечении срока POA-блокировка закрывается без зачисления средств оператору; возврат исходных USDC выполняется в Ethereum по правилам его HTLC.

Данные вызова содержат исходную тройку (chain_id, tx_hash, log_index), сумму, хеш HTLC, получателя, срок и eth_observation_block. Последнее поле фиксирует Ethereum-блок наблюдения для последующей проверки claimerUsedHash. Прекомпайл выполняет три шага:

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

Каждый вызов ссылается на одно финализированное событие Locked; объединения нескольких событий в один вызов нет.

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

Прекомпайл проверяет полномочия отправителя, но сам не запрашивает подтверждение исходного события в Ethereum. Поэтому подпись оператора недостаточна для доказательства обеспечения. Событие и его привязки отдельно проверяет верификатор.

После исполнения транзакций, но до принятия блока, верификатор проверяет новые строки bridge_mints. Для этого используются следующие запросы Ethereum JSON-RPC:

  • 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 по хешу и отправителю в ограниченном окне блоков после блокировки.
  • eth_call к claimerUsedHash(claimer, htlc_hash) в контракте моста: запрашивается состояние пары получатель/хеш на eth_observation_block строки (номер финализированного блока Ethereum, зафиксированный в calldata bridge_lock в момент подписания). Проверка на receipt_block была бы бессмысленной, потому что в блоке квитанции этот слот ещё равен нулю; проверка на текущем финализированном блоке ломала бы воспроизведение истории после успешного claim.

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

  1. Хеш HTLC. htlc_hash в bridge_mints должен совпадать с topics[1] события Locked. Это связывает блокировки в двух сетях с одним секретом.
  2. Привязка получателя. Адрес receiverOn2D, декодированный из log.data[0:32] Ethereum-события, должен совпадать с получателем в htlc_swaps. Это предотвращает перенаправление средств на адрес атакующего.
  3. Получатель Ethereum. claimer из topics[3] должен входить в OperatorAllowlist. Иначе оператор мог бы сослаться на блокировку с произвольным получателем обеспечения. Отсутствующий или некорректный адрес отклоняется. Ethereum-функция lock() сама по себе доступна любому участнику; ограничение вводит верификатор POA.
  4. Возврат Ethereum-блокировки. eth_getLogs ищет Refunded для хеша и отправителя в ограниченном окне блоков. Обнаруженный возврат исходной блокировки приводит к отказу.
  5. Использование хеша. claimerUsedHash(claimer, htlc_hash) читается на eth_observation_block, сохранённом в bridge_mints и включённом в корень состояния. Требуется receipt_block < eth_observation_block <= finalized_block. Зафиксированная высота позволяет воспроизводить историю после последующего claim. Нулевое значение у исторических строк после миграции пропускает эту проверку. Ограничение: оператор, формирующий произвольные данные вызова, может выбрать более ранний допустимый блок. Консенсус не доказывает, что высота совпадала с финализированным блоком в момент подписи; политика подписанта и операционные проверки остаются обязательными.

Поле htlc_hash обязательно для каждой строки bridge_mints. Строка без него отклоняется с ошибкой :missing_htlc_hash.

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

ПричинаЧто предотвращает
:not_foundКвитанция или лог не существует в Ethereum.
:wrong_contractАдрес лога не совпадает с Ethereum HTLC-контрактом.
:wrong_event_signaturetopic[0] лога не совпадает с сигнатурой Locked.
:chain_id_mismatchИдентификатор сети RPC не совпадает с eth_chain_id из строки.
:amount_mismatchСумма в данных события не совпадает с заявленным.
:not_finalizedБлок существует, но ещё не достиг финальности.
:htlc_hash_mismatchhtlc_hash в POA не совпадает с topics[1] Ethereum-события.
:receiver_mismatchreceiverOn2D в Ethereum-событии не совпадает с получателем HTLC в POA.
:ethereum_lock_refundedНайдено событие Refunded для этой блокировки в Ethereum.
:claimer_not_in_allowlistclaimer цитируемого Locked-события не входит в сконфигурированный список разрешённых адресов оператора (или отсутствует/неразбираем).
:hash_already_used_by_claimerclaimerUsedHash(claimer, hash) ненулевой на eth_observation_block (USDC уже забраны в Ethereum до подписания операции моста).
:eth_observation_block_missingНекорректный eth_observation_block (не историческое значение 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 (отдельное пополнение запрещено).
:rpc_unreachable / :rpc_http_errorТранспортные ошибки; трактуются как провал верификации.

Отказ проверки откатывает весь блок. Межсетевые RPC-запросы работают только на чтение. Транспортные ошибки :rpc_unreachable и :rpc_http_error допускают повтор с экспоненциальной задержкой; остальные несоответствия обрабатываются как нарушение правил проверки.

Проверка выполняется после BlockExecutor.execute_transactions, когда новые строки доступны в той же SERIALIZABLE-транзакции, и до Chain.StateRoot.compute. Честный верификатор не принимает блок с неподтверждённым обеспечением. Это защищает клиентов, которые используют результаты его проверки.

Для межсетевой проверки важен источник Ethereum-данных. Обычный удалённый RPC может вернуть недостоверные сведения; доверие к нему переносит соответствующую часть безопасности на оператора сервиса.

Рекомендуемая конфигурация направляет ETHEREUM_RPC_URL на локальный лёгкий клиент Helios. Он проверяет данные Ethereum, опираясь на доверенную контрольную точку и правила лёгкого клиента. Корректность и свежесть контрольной точки, версия клиента и защита локального процесса остаются обязательными условиями. Компрометация процесса или его RPC не равнозначна компрометации консенсуса Ethereum.

Использование Helios — настройка развёртывания, а не требование, принудительно проверяемое протоколом POA. Код обращается к ETHEREUM_RPC_URL. Оператор должен проверить, что за этим адресом находится ожидаемый клиент и что его конфигурация соответствует требованиям.

В коде эта зависимость оформлена как 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 возвращает htlc_hash_on_eth, sender_on_eth, claimer_on_eth, receiver_on_2d, receipt_block и finalized_block. CrossChainCheck сопоставляет их с записью POA и eth_observation_block. Проверка check_not_refunded ищет Refunded начиная с блока исходного события, а check_hash_not_used читает claimerUsedHash на сохранённой высоте. Членство получателя в Chain.Verifier.OperatorAllowlist проверяется локально, без RPC.

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

Ввод (Ethereum → POA):

  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. Оператор ждет финальности. Оркестратор отслеживает событие Locked, опрашивает eth_getBlockByNumber("finalized") и ждет, пока блок с блокировкой не станет финализированным. Это занимает примерно 12-15 минут в основной сети Ethereum.
  3. Оператор блокирует средства в POA (атомарно). Оператор отправляет bridge_lock(chain_id, tx_hash, log_index, amount, hash, Alice, deadline, eth_observation_block) на 0x2D00…0003, где eth_observation_block — финализированная голова Ethereum в момент подписания. Прекомпайл атомарно вставляет HTLC-своп и строку bridge_mints (включая зафиксированный блок наблюдения). Баланс оператора остаётся нулевым. Верификатор перепроверяет событие, htlc_hash, receiverOn2D, список получателей, отсутствие возврата и claimerUsedHash на eth_observation_block; при успехе блок принимается.
  4. Claim в POA HTLC (без специального разрешения). Любой, у кого есть секрет, вызывает claim(preimage) в POA HTLC по адресу 0x2D00…0001. Зачисление всегда идёт на receiver блокировки (Alice), не вызывающему. При вводе по котировке оператор хранит секрет до этого вызова claim — котировка приложения его не возвращает. После claim секрет есть в логе HTLC_Claimed на POA.
  5. Claim в Ethereum (без специального разрешения). Любой может отправить claim(sender, hash, preimage) в BridgeHTLC; USDC выплачиваются сконфигурированному claimer (при вводе в основную сеть — vault оператора). На сети 77 IntentWatcher не подписывает этот claim через NetHSM (BRIDGE_NETHSM_SIGNING_ENABLED=false). После квитанции операции POA процесс наблюдения отправляет claim с отдельного EOA оператора для оплаты газа (BRIDGE_ETH_HTLC_CLAIM_KEY_FILE). Чтобы остановить автоматические вызовы claim, отключите параметр файла ключа (или выключите watcher). Зависшие транзакции этого EOA заменяются по nonce и цене газа из eth_getTransactionByHash, в пределах жёсткого лимита SignerPolicy. Исключительное восстановление (RPC недоступен) — через mix bridge.record_eth_claim по инструкции оператора.

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

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

Для основной сети 77 сброс БД недопустим как обычный шаг обновления. Перед изменением ABI или корня состояния нужны проверенный план миграции, резервная копия, согласованная активация на производителе и верификаторах и проверка воспроизведения истории. Ниже описана совместимость исторической миграции.

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

  • Только для одноразовой среды разработки: сброс БД (mix ecto.reset или новый генезис) до миграции 20260604130100.
  • Миграция без сброса: существующие строки получают eth_observation_block = 0 и пропускают проверку состояния хеша при повторном исполнении истории; новые записи должны содержать > 0 через прекомпайл.
  • Ethereum RPC: верификаторам нужен доступ к архивному состоянию для eth_call к claimerUsedHash на исторических блоках наблюдения.

Ключ оператора POA подписывает bridge_lock, а операционный ключ Ethereum — OperatorVault.bridgeOut. Управление vault — отдельная роль, которая меняет получателей, лимиты, подписанта и реализацию контракта. Ethereum-claim не требует операционного ключа: любой наблюдатель может отправить раскрытый секрет, но получателя изменить нельзя.

Политика подписанта дополнительно ограничивает допустимые контракты и методы. Это отдельный уровень защиты, который проверяется вместе с правилами контрактов и верификатора.

УгрозаГраница защиты и остаточный риск
Компрометация ключа оператора POAЧестный верификатор проверяет событие Ethereum, получателя, разрешённого claimer и остальные привязки. Одной подписи оператора для принятия произвольного выпуска недостаточно. Самофинансируемая блокировка с разрешённым vault требует отдельного рассмотрения — см. ниже.
Компрометация операционного ключа EthereumbridgeOut ограничен bridgeOutAllowlist, perTxCap и скользящим cumulativeCap за 24 часа. Ключ не меняет эти правила. Он сам по себе не даёт секретов ожидающих HTLC; хранение секретов ввода по котировке оценивается отдельно.
Совместная компрометация ключейАтакующий может профинансировать Ethereum-lock с claimer = vault и своим receiverOn2D. Проверка claimerUsedHash выявляет уже использованный хеш на указанном блоке наблюдения, но не доказывает, что злонамеренно выбранный eth_observation_block равен финализированной высоте в момент подписи. После POA-claim секрет публичен, и любой наблюдатель может отправить Ethereum-claim в пользу vault до срока. Лимиты vault сохраняются, пока не скомпрометировано управление.
Компрометация LPМожет остановить котировки, нарушить исполнение свопа или привести к потере капитала LP. Эта роль не получает управление vault или выпуском bridge-in. Публичный вывод через LP сейчас выключен.
Компрометация управления vaultПозволяет сменить подписанта, разрешить новые адреса, снять лимиты или обновить контракт. Под угрозой весь пул. Мультиподпись, аппаратные ключи и задержка активации изменений требуют отдельной проверки.
Фиктивный bridge_lock или необеспеченный блокМежсетевая проверка выполняется верификатором после исполнения транзакций и до принятия блока. Защита зависит от честного верификатора и достоверного Ethereum RPC; одна проверка отправителя в прекомпайле недостаточна.
Истечение срокаОбычный POA lock возвращает ранее списанные средства отправителю. Для bridge_lock возврат закрывает запись без зачисления POA; исходные USDC возвращаются в Ethereum по правилам его HTLC.
Компрометация Helios или RPCЛокальный процесс может передавать ложные данные независимо от честности Ethereum. Проверяйте образ, контрольную точку и сетевую границу; сторонний RPC добавляет доверие к его оператору.
Повтор исходного событияУникальный eth_event_id и включение bridge_mints в корень состояния связывают проверку повторов с воспроизведением блока.

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

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

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

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

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

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

До публикации новых блокировок с claimer = vault добавьте vault в OperatorAllowlist всех верификаторов. Иначе проверка получателя отклонит такие операции. Порядок развёртывания:

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

Блокировки с новым получателем, созданные до распространения списка, не пройдут проверку ввода. Незавершённые Ethereum-блокировки придётся возвращать после срока по правилам HTLC.

Во время миграции OperatorAllowlist только расширяется: {legacy_EOA} становится {legacy_EOA, vault}. Старый адрес сохраняется по двум причинам:

  • Воспроизведение истории. Старые события с claimer = legacy_EOA должны проходить проверку при синхронизации нового узла.
  • Переходный период. Пока приложения переключаются, допустимы блокировки со старым адресом и с vault.

После переключения оркестратор создаёт новые bridge_lock только для событий с vault. Старый адрес остаётся в списке для истории. Его удаление требует отдельной процедуры с доказательством совместимости исторического воспроизведения; давность блоков сама по себе недостаточна.

Верификатор проверяет межсетевые данные, корень состояния фиксирует записи обеспечения, а прекомпайлы выполняют операции HTLC. Мост объединяет эти механизмы в протокол расчётов.