State roots — верификация сети без валидаторов
Большинство блокчейнов полагаются на набор валидаторов для достижения консенсуса о текущем состоянии. У 2D один block producer. Это делает создание блоков быстрым и простым, но также означает, что встроенного «второго мнения» нет. Если producer скомпрометирован, он может записать любое состояние.
State roots это исправляют. После каждого блока producer вычисляет криптографический отпечаток всего изменяемого состояния и фиксирует его в хеше блока. Любой клиент, который повторно прогоняет транзакции блока от предыдущего состояния, может независимо пересчитать этот отпечаток и сравнить. Несовпадение означает, что producer солгал. Набор валидаторов не нужен.
Что входит в state root
Заголовок раздела «Что входит в state root»Четыре таблицы из state-схемы, отсортированные по первичному ключу. Каждая строка хешируется через keccak256, результаты объединяются:
- accounts (address, balance, nonce). Каждый аккаунт, когда-либо получавший USD-stable.
- htlc_swaps (hash, sender, receiver, amount, deadline, status, preimage). Каждый активный или завершённый атомарный своп.
- precompiles (address, name, handler, enabled). Набор зарегистрированных precompile-контрактов.
- bridge_mints (eth_event_id, исходная тройка, amount, координаты блока). По одной строке на каждый Ethereum
Lockedevent, под который оператор сделал refill. Включение строго обязательно: без него dedup-инвариант жил бы только в самой таблице, и producer мог бы дважды заминтить под один и тот же event, всё равно сойдясь по replay с честным верификатором. Cross-chain check, который перепроверяет каждую строку, описан в статье про bridge.
blocks_tip (singleton, кеш текущего указателя на голову) намеренно исключён. Это кеш для быстрого доступа, не консенсусное состояние. Его включение создало бы циклическую зависимость: state root надо вычислять до обновления tip, но обновление tip происходит в той же транзакции.
Сам root:
accounts_root = keccak256( row_hash(account_1) || row_hash(account_2) || ... )htlc_root = keccak256( row_hash(swap_1) || row_hash(swap_2) || ... )precompiles_root = keccak256( row_hash(precompile_1) || ... )bridge_mints_root = keccak256( row_hash(mint_1) || ... )
state_root = keccak256( accounts_root || htlc_root || precompiles_root || bridge_mints_root )Каждый row hash кодирует поля в каноническом бинарном формате (целые числа фиксированной ширины, строки с длиной-префиксом, нормализованные Decimal). Два арифметически равных баланса всегда дают одинаковые байты, независимо от того, как Decimal был внутренне сконструирован.
Как block hash фиксирует state root
Заголовок раздела «Как block hash фиксирует state root»Хеш каждого блока:
block_hash = keccak256( block_number (8 байт) || parent_hash (32 байта) || timestamp (8 байт) || tx_root (32 байта, хеш всех tx hash-ей в порядке выполнения) || state_root (32 байта, Merkle root выше))Изменение любого баланса, статуса HTLC или регистрации precompile без соответствующей валидной транзакции меняет state root, что меняет block hash, что ломает цепочку parent_hash для всех последующих блоков. Одна изменённая строка каскадирует в детектируемый разрыв цепи.
Что верификатор с этим делает
Заголовок раздела «Что верификатор с этим делает»В составе сети есть независимый verifier-клиент. Для каждого блока он:
- Получает блок (header + сырые подписанные транзакции) от upstream-узла.
- Повторно выполняет транзакции на своей копии предыдущего состояния.
- Вычисляет свой state root.
- Сравнивает с заявленным producer-ом root.
- Совпадает: фиксирует блок и отдаёт проверенное состояние кошелькам и RPC-клиентам.
- Не совпадает: откатывает транзакцию, логирует критический алерт, отказывает в обслуживании.
Верификатор работает отдельным BEAM-узлом со своей базой данных. У него нет прямого доступа к хранилищу producer. Пользователи подключаются к RPC верификатора, не producer-а. Несколько верификаторов могут работать независимо. Запустить свой может кто угодно.
Верификатор, зафиксировавший блок, ретранслирует его на своём собственном block feed. Другие верификаторы могут подписаться на него вместо producer-а. Верификаторы могут выстраиваться в цепочку или дерево:
Producer ──▶ Верификатор A ──▶ Верификатор C ▶ Верификатор B ──▶ Верификатор DКаждый верификатор проверяет каждый блок независимо, откуда бы он его ни получил. Модель доверия одинакова: проверь, потом обслуживай.
Как блоки и транзакции перемещаются между нодами
Заголовок раздела «Как блоки и транзакции перемещаются между нодами»Producer и verifier это два BEAM-узла (Erlang VM), соединённых через Erlang distribution. Все данные идут по одному шифрованному каналу. Без HTTP, без внешних очередей сообщений.
Блоки (producer к верификаторам): после коммита каждого блока в базе producer emit-ит событие через GenStage pipeline. GenStage это Elixir-библиотека для producer-consumer потоков с backpressure. Каждый подписанный верификатор получает каждый блок (broadcast fan-out, не round-robin). Если верификатор отстаёт или отключается, producer буферизирует до 1000 блоков; старые отбрасываются.
При старте (или после разрыва связи) верификатор сначала догоняет, а потом подписывается на live-feed. Он спрашивает у upstream-узла текущий tip, затем запрашивает блоки пачками от своего последнего известного блока, проверяя каждый. Как только догнал, переключается на live GenStage-подписку. Механизм работает одинаково независимо от того, upstream это producer или другой верификатор.
Событие блока несёт header (number, hash, parent hash, timestamp, state root, transactions root) плюс raw signed транзакции в порядке выполнения. Для Ethereum-формата raw bytes содержат подпись, так что верификатор может независимо восстановить отправителя. Для Tron protobuf транзакций (где подписи отбрасываются на входе, хранится только unsigned protobuf) адрес отправителя включён явно.
Транзакции (пользователи к producer-у): пользователи отправляют транзакции на RPC верификатора (единственный публичный endpoint). Верификатор пересылает каждую транзакцию producer-у через fire-and-forget сообщение по Erlang distribution. Producer записывает её в mempool и забирает на следующем блоке.
У producer-а нет публичных портов. Он слушает только Erlang distribution (два порта, зафайрволенные на IP верификатора, TLS). Весь пользовательский трафик идёт через верификатор.
Пользователи/Кошельки/Explorer │ ▼ Верификатор (публичный RPC) │ ▲ txs (cast) blocks (GenStage) │ │ ▼ │ Producer (без публичных портов)Дополнительные верификаторы могут подключаться к первому вместо прямого соединения с producer-ом. Каждый верификатор ретранслирует проверенные блоки, поэтому топология может быть звездой, цепочкой или деревом в зависимости от развёртывания.
Текущий подход и масштабирование
Заголовок раздела «Текущий подход и масштабирование»Текущая реализация это sorted-hash: запрос всех строк из каждой state-таблицы, сортировка по первичному ключу, хеширование, конкатенация. O(n) на блок по всему объёму состояния. Для сети с менее чем миллионом аккаунтов этого достаточно.
Когда состояние вырастет, план — мигрировать на инкрементальное Merkle-дерево, которое обновляет только строки, изменившиеся в текущем блоке. Стоимость на блок снижается до O(k log n), где k — количество изменённых строк. Значение state root остаётся тем же (это свойство состояния, не алгоритма), поэтому миграция прозрачна для верификаторов.
Почему это важно для cross-chain мостов
Заголовок раздела «Почему это важно для cross-chain мостов»Первый реальный потребитель этого свойства — bridge. На каждый refill, который сабмитит оператор, верификатор независимо запрашивает указанный Ethereum-event через локальный helios-сайдкар и отклоняет блок, если что-то не сходится. State roots делают это возможным: они дают каждому клиенту верифицируемую картину того, что реально произошло on-chain. Строка bridge_mints со своей исходной тройкой, dedup-id и amount хешируется в цепь, и скомпрометированный producer не может тихо переписать её задним числом.