Мост: HTLC-расчёт и атомарный bridge-lock
В основной сети 77 вывод из POA в Ethereum выключен. Описанный ниже вывод через LP реализован за отдельным переключателем. Не используйте POST /api/v1/bridge/out/quote и не блокируйте средства для этого сценария до отдельно проверенного включения. Ввод Ethereum → POA работает независимо от него.
Мост связывает блокировку USDC в Ethereum с HTLC, закреплённым за получателем в POA. Верификатор проверяет исходное событие и привязки перед принятием блока. HTLC ограничивает условия расчёта; безопасность также зависит от реализации, доступности обеих сетей, подписантов и управления OperatorVault.
Условия расчёта через HTLC
Заголовок раздела «Условия расчёта через HTLC»Блокировка содержит хеш секрета, получателя и срок. До истечения срока любой участник, знающий секрет, может вызвать claim; средства получает адрес, указанный в блокировке. После срока незавершённую блокировку можно закрыть через refund по правилам соответствующей сети.
В пользовательском HTLC секрет выбирает пользователь. При вводе по котировке (quoted inflow) секрет до исполнения хранит оператор; котировка не возвращает его приложению. Эти сценарии имеют разные границы доверия. Нельзя описывать весь ввод как процесс, в котором только пользователь контролирует раскрытие секрета.
При вводе через мост USDC после Ethereum-claim поступают в OperatorVault. Операционный ключ ограничен разрешёнными получателями и лимитами, а управление vault может менять политику и обновлять контракт. Поэтому отсутствие метода с именем unlock не устраняет риск компрометации управления или ошибки общего кода.
Bridge-lock и инвариант эмиссии
Заголовок раздела «Bridge-lock и инвариант эмиссии»При вводе через мост каждое пополнение связано с финализированным событием 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. Прекомпайл выполняет три шага:
- Отклоняет вызов, если отправитель не равен
bridge_operator_address. Аккаунт оператора ограничен только вызовами прекомпайлов; обычные переводы с адреса оператора блокируются исполнителем блоков. - Вставляет строку HTLC-свопа (блокировка с привязкой к получателю) и строку
bridge_mints(обеспечение эмиссии) с ключомeth_event_id = keccak256(eth_chain_id ‖ eth_tx_hash ‖ eth_log_index). Первичный ключ гарантирует, что одна и та же тройка не может привести к повторному выпуску токенов. Рядом с идентификатором события сохраняетсяhtlc_hash, связывая выпуск с конкретным HTLC-свопом. - Если обе вставки прошли успешно, генерирует событие
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, зафиксированный в calldatabridge_lockв момент подписания). Проверка наreceipt_blockбыла бы бессмысленной, потому что в блоке квитанции этот слот ещё равен нулю; проверка на текущем финализированном блоке ломала бы воспроизведение истории после успешного claim.
После подтверждения существования и финальности события Locked верификатор проводит пять проверок:
- Хеш HTLC.
htlc_hashвbridge_mintsдолжен совпадать сtopics[1]событияLocked. Это связывает блокировки в двух сетях с одним секретом. - Привязка получателя. Адрес
receiverOn2D, декодированный изlog.data[0:32]Ethereum-события, должен совпадать с получателем вhtlc_swaps. Это предотвращает перенаправление средств на адрес атакующего. - Получатель Ethereum.
claimerизtopics[3]должен входить вOperatorAllowlist. Иначе оператор мог бы сослаться на блокировку с произвольным получателем обеспечения. Отсутствующий или некорректный адрес отклоняется. Ethereum-функцияlock()сама по себе доступна любому участнику; ограничение вводит верификатор POA. - Возврат Ethereum-блокировки.
eth_getLogsищетRefundedдля хеша и отправителя в ограниченном окне блоков. Обнаруженный возврат исходной блокировки приводит к отказу. - Использование хеша.
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_signature | topic[0] лога не совпадает с сигнатурой Locked. |
:chain_id_mismatch | Идентификатор сети RPC не совпадает с eth_chain_id из строки. |
:amount_mismatch | Сумма в данных события не совпадает с заявленным. |
:not_finalized | Блок существует, но ещё не достиг финальности. |
:htlc_hash_mismatch | htlc_hash в POA не совпадает с topics[1] Ethereum-события. |
:receiver_mismatch | receiverOn2D в Ethereum-событии не совпадает с получателем HTLC в POA. |
:ethereum_lock_refunded | Найдено событие Refunded для этой блокировки в Ethereum. |
:claimer_not_in_allowlist | claimer цитируемого Locked-события не входит в сконфигурированный список разрешённых адресов оператора (или отсутствует/неразбираем). |
:hash_already_used_by_claimer | claimerUsedHash(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. Честный верификатор не принимает блок с неподтверждённым обеспечением. Это защищает клиентов, которые используют результаты его проверки.
Helios: что стоит за «Ethereum RPC»
Заголовок раздела «Helios: что стоит за «Ethereum RPC»»Для межсетевой проверки важен источник 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()}endverify_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):
- Пользователь отправляет 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. Верификатор проверяет все эти данные. - Оператор ждет финальности. Оркестратор отслеживает событие
Locked, опрашиваетeth_getBlockByNumber("finalized")и ждет, пока блок с блокировкой не станет финализированным. Это занимает примерно 12-15 минут в основной сети Ethereum. - Оператор блокирует средства в 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; при успехе блок принимается. - Claim в POA HTLC (без специального разрешения). Любой, у кого есть секрет, вызывает
claim(preimage)в POA HTLC по адресу0x2D00…0001. Зачисление всегда идёт наreceiverблокировки (Alice), не вызывающему. При вводе по котировке оператор хранит секрет до этого вызова claim — котировка приложения его не возвращает. После claim секрет есть в логеHTLC_Claimedна POA. - Claim в Ethereum (без специального разрешения). Любой может отправить
claim(sender, hash, preimage)вBridgeHTLC; USDC выплачиваются сконфигурированномуclaimer(при вводе в основную сеть — vault оператора). На сети 77IntentWatcherне подписывает этот 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 требует отдельного рассмотрения — см. ниже. |
| Компрометация операционного ключа Ethereum | bridgeOut ограничен 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 всех верификаторов. Иначе проверка получателя отклонит такие операции. Порядок развёртывания:
- Развернуть
OperatorVault. Настроить управление, ключ подписи,perTxCap,cumulativeCapиbridgeOutAllowlist. - Добавить адрес vault-а в
OperatorAllowlistверификатора и распространить конфигурацию на каждого честного верификатора. - Только после распространения шага 2 приложения и сервисы заявок могут начать публиковать Ethereum-блокировки с
claimer = vault.
Блокировки с новым получателем, созданные до распространения списка, не пройдут проверку ввода. Незавершённые Ethereum-блокировки придётся возвращать после срока по правилам HTLC.
Сохранение исторических получателей
Заголовок раздела «Сохранение исторических получателей»Во время миграции OperatorAllowlist только расширяется: {legacy_EOA} становится {legacy_EOA, vault}. Старый адрес сохраняется по двум причинам:
- Воспроизведение истории. Старые события с
claimer = legacy_EOAдолжны проходить проверку при синхронизации нового узла. - Переходный период. Пока приложения переключаются, допустимы блокировки со старым адресом и с vault.
После переключения оркестратор создаёт новые bridge_lock только для событий с vault. Старый адрес остаётся в списке для истории. Его удаление требует отдельной процедуры с доказательством совместимости исторического воспроизведения; давность блоков сама по себе недостаточна.
Место моста в архитектуре сети
Заголовок раздела «Место моста в архитектуре сети»Верификатор проверяет межсетевые данные, корень состояния фиксирует записи обеспечения, а прекомпайлы выполняют операции HTLC. Мост объединяет эти механизмы в протокол расчётов.