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

Синхронизация дайджестов

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

Сканирование через RPC занимает десятки минут; дайджесты сокращают время до секунд.

Реализация находится в SDK Anon и подключается к движку Railgun при инициализации. Все клиенты используют одну реализацию и один формат передачи, описанный здесь. О приватности, а не только скорости, читайте в Синхронизации кошелька.

Проблема

Движок хранит локальное дерево Меркла зашифрованных нот. Для построения с нуля нужны все события коммитментов с развёртывания контрактов.

Стандартная синхронизация запрашивает Graph/Subgraph. В Ethereum это пагинация сотен тысяч событий с хранением всего набора в памяти при построении дерева. Это медленно, уязвимо к нехватке памяти, а запросы несут специфичные для клиента фильтры.

Как это работает

Индексатор непрерывно обрабатывает события блокчейна и загружает сжатые чанки с отдельными файлами в CDN. Клиенты скачивают файлы вместо запросов индексатору.

Дайджесты заменяют quickSyncEvents и quickSyncRailgunTransactionsV2 и полностью отключают встроенную холодную синхронизацию Graph. Полная загрузка уже выполнена, поэтому Graph лишь добавлял неудачную попытку перед переходом. Резервного Graph нет. Пока CDN недоступен, синхронизация не идёт.

Структура CDN

v4/{chainId}/
  meta.json                              <- root manifest
  chunk-index.json                       <- per-chunk block/sequence ranges
  chunks/
    0/
      meta.json                          <- per-chunk metadata (block range, checksums, counts)
      commitments.json.gz               <- flat array of commitment events
      nullifiers.json.gz                <- flat array of nullifier events
      unshields.json.gz                 <- flat array of unshield events
      transactions.json.gz             <- flat array of transaction events
      tree-utxo-{n}.bin.gz             <- packed merkle tree, present on finalizing chunks
      tree-txid-{n}.bin.gz
    1/
      meta.json
      commitments.json.gz
      ...
  tip/
    meta.json                            <- mutable tip metadata
    commitments.json.gz
    nullifiers.json.gz
    unshields.json.gz
    transactions.json.gz
    tree-utxo-active.bin.gz             <- packed boundary tree
    tree-txid-active.bin.gz

Рабочие адреса:

  • Корневой манифест: https://digest.anon.inc/v4/{chainId}/meta.json
  • Индекс чанков: https://digest.anon.inc/v4/{chainId}/chunk-index.json
  • Метаданные чанка: https://digest.anon.inc/v4/{chainId}/chunks/{id}/meta.json
  • Файл событий: https://digest.anon.inc/v4/{chainId}/chunks/{id}/commitments.json.gz

Каждый тип хранится отдельным gzip-файлом с плоским JSON-массивом. Можно выбирать типы: для обнаружения расходования по нуллификаторам остальные файлы не нужны.

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

Уровни кеширования

Со временем объекты проходят три уровня:

Уровень Cache-Control Объекты
Текущий public, max-age=15 Корень, chunk-index.json, всё в tip/
Окно исправления public, max-age=300 Последний запечатанный чанк
Неизменяемый public, max-age=31536000, immutable Все более старые чанки

Пятиминутное окно позволяет исправить раннюю регрессию повторным PUT без новой версии пути. После следующего запечатывания предыдущий чанк получает заголовок неизменяемости на месте; тело не меняется.

Эта ступень снимает нагрузку с origin: до года объект отдаётся с периферии, примерно с одним заполнением на объект и точку CDN независимо от числа кошельков.

Корневой манифест

Корневой meta.json намеренно мал и почти постоянного размера: несколько сотен байт и реестр снимков деревьев.

{
  "version": 5,
  "formatVersion": 2,
  "chainId": 1,
  "txidVersion": "V2_PoseidonMerkle",
  "latestSealedChunk": 198,
  "latestBlock": 25725624,
  "tipChecksum": "sha256_of_tip_meta_json",
  "generatedAt": "2026-08-10T15:57:59Z",
  "live": {
    "txidLeafCount": 125336,
    "utxoLeafCount": 252705,
    "endBlock": 25725609
  },
  "treeSnapshot": { /* see Tree Snapshots */ }
}

Встроенного массива чанков нет. latestSealedChunk задаёт диапазон от 0 до N. tipChecksum — SHA-256 содержимого meta.json текущего tip для обнаружения изменений. Несовпадение formatVersion CDN и кеша вызывает очистку и повторную загрузку.

live объединяет позицию tip с запечатанной границей, позволяя атомарно прочитать обе одним запросом без гонки.

Индекс чанков

chunk-index.json содержит диапазоны блоков и последовательностей всех запечатанных чанков:

{
  "chainId": 1,
  "chunks": [
    { "id": 0, "startBlock": 15725039, "endBlock": 16234121, "firstSeq": 0, "lastSeq": 799 }
  ]
}

В чанке без транзакций firstSeq равен -1. Одного запроса достаточно для поиска блока или последовательности без обхода метаданных. Если индекс ещё не опубликован, ответ 404 запускает обход всех чанков.

Метаданные чанка

В каждом каталоге есть meta.json с диапазоном блоков, количеством событий и суммами файлов:

{
  "chunkId": 0,
  "formatVersion": 3,
  "startBlock": 15725039,
  "endBlock": 16234121,
  "counts": { "commitments": 1081, "nullifiers": 1323, "unshields": 595, "transactions": 800 },
  "files": {
    "commitments": { "checksum": "a1b2c3...", "size": 245000, "jsonSize": 720000 },
    "nullifiers": { "checksum": "d4e5f6...", "size": 180000, "jsonSize": 510000 },
    "unshields": { "checksum": "g7h8i9...", "size": 195000, "jsonSize": 580000 },
    "transactions": { "checksum": "j0k1l2...", "size": 91000, "jsonSize": 260000 }
  },
  "sealedAt": "2026-03-15T..."
}

Суммы — SHA-256 распакованного JSON. size — байты gzip, jsonSize — размер после распаковки.

Счётчики formatVersion чанка и корня независимы. Чанк formatVersion: 3 при корне formatVersion: 2 нормален; сравнивайте каждый с однотипным объектом.

Запечатывание

Цель — около 3 000 событий всех типов на чанк. Новый запечатывается при превышении примерно 6 000 в tip. Границы совпадают с блоками: события одного блока не разделяются. Ethereum приближается к 200 чанкам, L2 существенно меньше.

Клиент

Функции передаются движку напрямую при инициализации, без глобальных хуков. При запросе событий:

  1. Получить корень: один маленький запрос даёт число чанков, сумму tip и текущую позицию.

  2. Скачать недостающее: чанки кешируются отдельно; запечатанные переиспользуются постоянно по факту наличия. Tip обновляется при смене tipChecksum. При промахе сначала скачивается meta.json, затем параллельно файлы событий.

  3. Собрать: объединить в AccumulatedEvents, упорядочить коммитменты для вставки в дерево и передать движку.

Выбор зависит от высоты блока и кеша, не от содержимого кошелька. Одинаковая позиция даёт одинаковые запросы.

Корректность кеша

Неизменяемые URL удобны для кеширования, но опасны при исправлениях. После перенумерации producer переписывает те же URL, а промежуточный кеш может отдавать старые байты. Перестроение тогда останавливается до tip без ошибки.

Две меры устраняют это:

  • Всегда получать запечатанные файлы с cache: 'reload'. Клиент обходит свой HTTP-кеш и обращается к CDN. В стабильном состоянии используется постоянное хранилище чанков без повторной сети. Правило действует для всех путей восстановления.
  • Добавлять ?v={tipChecksum} к tip. При изменении содержимого меняются ключи кеша от браузера до периферии; старые записи больше не запрашиваются, ждать истечения не нужно.

Второе — мера корректности. Даже при коротком TTL одна ошибочная политика незаметно закрепляет старые данные. В августе 2026 CDN переопределил TTL на max-age=14400: кошельки часами читали старый tip, в том числе сломанную цепочку хешей после её исправления producer. Смена ключа по содержимому исключает такие повторные попадания со стороны клиента независимо от настройки посредников.

Синхронизация TXID

Транзакции ускоряют синхронизацию TXID-дерева. UTXO-события объединяются в объект памяти, а транзакции идут пакетами по курсору, соответствуя transactions.json.gz из Форматов файлов.

Движок повторяет QuickSyncRailgunTransactionsV2(chain, latestGraphID). SDK:

  1. Разбирает latestGraphID как десятичную метку sequence. Отсутствующий, пустой или старый нечисловой курсор означает начало до первой последовательности.
  2. Находит чанк курсора, получает достаточно данных для пакета, включая текущую голову для проверки. При догоняющей синхронизации берёт полный tip/, в рабочем режиме — недавние события по последовательности.
  3. Оставляет sequence > cursor.sequence, удаляет повторы с приоритетом запечатанных данных, сортирует по sequence и проверяет каждый хеш-переход. Пропуск номера допустим лишь если цепь доказывает отсутствие пропущенного листа. Повреждённая или непроверяемая связь останавливает выдачу; движку передаётся только допустимый префикс в пределах размера пакета.

Движок добавляет транзакции по порядку и продолжает с последним graphID, десятичной строкой последовательности. Метки и канонические позиции листьев — разные координаты; курсор не является индексом дерева.

Предзагрузка

При инициализации, до запросов движка, манифесты всех цепей и запечатанные чанки с tip параллельно загружаются локально. К моменту запроса данные уже в кеше, что делает повторный запуск почти мгновенным.

Текущий опрос

После начальной загрузки есть два режима:

  • API: основной, раз в 5 секунд запрашивает /api/v1/indexer/head/{chainId}/status. При смене tipBlock получает /recent; если ответ пуст, а клиент отстаёт, расширяет диапазон.
  • CDN: резервный, раз в 30 секунд при status: "not_configured" опрашивает корневой meta.json и сравнивает tipChecksum.

Новые события в обоих случаях вызывают лёгкое обновление баланса.

После 10 минут без мыши, клавиатуры, прокрутки и касаний опрос приостанавливается; активность возобновляет его.

Только API достигает инфраструктуры Anon и передаёт состояние клиента: ?fromBlock / ?fromSequence — позиция в цепи, а не условие кошелька. См. Приватность.

Последовательность опроса API

Каждый пятисекундный такт:

  1. Статус: GET /api/v1/indexer/head/{chainId}/status возвращает tipBlock, cdnBlock, reorgEpoch и работоспособность.
  2. Проверка реорганизации: рост reorgEpoch запускает восстановление вместо обычной обработки.
  3. Первый опрос: записывает tipBlock и cdnBlock как базу без загрузки событий.
  4. Изменения: если tipBlock прежний, действий нет.
  5. Фиксация границы: новый tipBlock записывается сразу, исключая дублирующую работу параллельных запросов.
  6. Дельта: GET /api/v1/indexer/head/{chainId}/recent возвращает события между cdnBlock и tipBlock в JSON с base64; непустые передаются движку.
  7. Расширение: если /recent вернул 0 после запечатывания, а локальная позиция ниже tipBlock, повторяется запрос с ?fromBlock={clientBlock} от последнего известного блока.

Эндпоинт недавних событий

GET /api/v1/indexer/head/{chainId}/recent[?fromBlock=N | ?fromSequence=N]

Возвращает дельту после cdnBlock или заданного курсора:

{
  "chainId": "1",
  "tipBlock": "24729300",
  "cdnBlock": "24729271",
  "recentEvents": 3,
  "eventsJson": "<base64-encoded JSON>"
}

eventsJson декодируется в те же четыре массива, что CDN tip. Клиент сразу передаёт события движку, не дожидаясь сохранения; запись в локальное хранилище идёт асинхронно в фоне.

Блок или последовательность. ?fromBlock подходит UTXO с ключом по содержимому, но может пропустить TXID ниже границы блока. TXID использует ?fromSequence и проверку хеш-цепи. Старый сервер может игнорировать параметр, поэтому при необходимости берётся полный tip/. Пропуск номера сам по себе не доказывает отсутствия листа: критерий — непрерывная цепь хешей.

Восстановление после реорганизации

Индексатор сообщает реорганизации через /status. Клиент выполняет обнаружение и восстановление.

Обнаружение

В /status есть три поля:

  • reorgEpoch — монотонный счётчик, растущий при каждом обнаружении.
  • reorgForkBlock — блок разветвления.
  • reorgTxCount — число транзакций Railgun для отката TXID.

Рост reorgEpoch относительно прошлого опроса запускает восстановление. Значение сохраняется локально для обнаружения изменений, случившихся при закрытом клиенте.

Шаги восстановления

  1. Откат TXID: clearLeavesForInvalidVerificationHash(txCount) удаляет затронутые листья, затем invalidateTXOsCacheAllWallets() заставляет перечитать БД.
  2. Очистка событий: удаляются локальные записи с blockNumber > forkBlock.
  3. Сброс SyncMeta: если highestBlock больше forkBlock, он сбрасывается на forkBlock.
  4. Повторная синхронизация: загрузка CDN tip и полный проход UTXO → TXID принимают исправленную цепь.

Явный откат UTXO не нужен: валидатор корня пропускает устаревшие коммитменты и принимает исправленные при следующем проходе.

Отложенные реорганизации

Если UTXO-сканирование ещё читает хранилище, немедленное восстановление изменило бы читаемые данные. Операция откладывается до следующего такта и выполняется до новых событий, после завершения сканирования.

Восстановление рассинхронизации

Сложнее случай без явной ошибки, когда клиент не достигает tip: дыра TXID, сместившиеся позиции листьев или переписанные уже прочитанные байты. Синхронизация идёт бесконечно без прогресса.

SDK использует независимые сигналы:

  • Застревание: неподвижный курсор классифицируется вместо бесконечных повторов.
  • Перенумерация: несовпадение принятой последовательности и CDN указывает на переписанную историю.
  • Прерыватель прогресса: повторные пакеты без новых листьев останавливают цикл.
  • Повторная гидратация снимка: на дереве из снимка застрявший TXID восстанавливается повторным принятием снимка и заполнением данных.

Полный сброс TXID ошибочен для обрезанного по дате создания снимка: перестроение с 0 требует ранних UTXO-данных, которые намеренно не сохранялись, и повторяет зависание. Поэтому используется повторная гидратация.

Автовосстановление ограничено одним запуском на цепь за сеанс. Лимит тратится до начала, включая неудачу; далее требуется ручной путь, а не новое автоматическое разрешение.

Форматы файлов событий

У чанков и tip единый формат. Каждый каталог и tip/ содержат четыре gzip-файла с плоскими массивами:

Файл Элемент массива Назначение
commitments.json.gz Пакет коммитментов Листья UTXO
nullifiers.json.gz Нуллификатор Обнаружение расходования
unshields.json.gz Деэкранирование Записи вывода
transactions.json.gz Транзакция Railgun Листья TXID

Файлы одного типа совместимы побайтно. Различаются лишь обёртка метаданных и жизненный цикл, см. Чанки и tip; парсер одинаков.

Правила кодирования

  • Хеши и элементы поля: дополненный нулями hex из 64 символов, 32 байта. Префикс 0x зависит от поля; ошибка ломает совпадение Poseidon.
  • Целые сверх 2^53 (value, amount, fee, tokenSubID) — десятичные строки, не числа.
  • Блоки, время, позиции дерева и индексы — числа JSON.
  • Адреса (toAddress, tokenAddress) имеют 0x и контрольный регистр EIP-55.
  • tokenType: 0 = ERC20, 1 = ERC721, 2 = ERC1155.
Поле Префикс 0x?
transaction.commitments[], transaction.nullifiers[] да
transaction.txid / boundParamsHash / verificationHash / railgunTxid нет
commitment.hash / txid / npk / shieldKey / encryptedBundle[] / encryptedRandom[] нет
preImage.value да
TransactCommitmentV2 ciphertext.memo да
TransactCommitmentV2 ciphertext.iv / tag / data[] / annotationData / ключи просмотра нет
nullifier.nullifier / nullifier.txid нет
unshield.txid нет
адреса да, EIP-55

transactions.json.gz

Поток TXID: элемент — одна внутренняя транзакция Railgun. Одна внешняя транзакция с несколькими внутренними создаёт записи с общим txid, но разными sequence, commitments, nullifiers, boundParamsHash, railgunTxid, utxoBatchStartPositionOut.

{
  "version": 2,
  "sequence": 54307,
  "commitments": ["0x1b02b669258e874b2a3d3e2c58e5b4ee7f38dbb8470766a89a119db47eaf4d9b"],
  "nullifiers": ["0x298937ed3be6af8bf16ef3e0a70212e1012ce3902a15bfe2f23eae05df3f27ec"],
  "boundParamsHash": "129bda4babbc749540d03d7c4514c5ecda06009a984ee7dbf653e6ca4fb762ac",
  "blockNumber": 449432836,
  "txid": "63459180506a7477924d1217245f967f22c32a2dbb0094c374a4d66eb236be5d",
  "utxoTreeIn": 1,
  "utxoTreeOut": 1,
  "utxoBatchStartPositionOut": 35669,
  "timestamp": 1775426303,
  "verificationHash": "2395448c1a31f6b4e018c213f27801421f13d71d5bd7c94d7fbbe30a13c3b15f",
  "railgunTxid": "2a79bc02e174d1ff5b6675b04abe3fde748d47fc40628de8fa1d21e9d7c09137",
  "unshield": {
    "tokenData": { "tokenAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", "tokenType": 0, "tokenSubID": "0" },
    "toAddress": "0x5aD95C537b002770a39dea342c4bb2b68B1497aA",
    "value": "1000000000000000"
  }
}
Поле Тип Примечания
version number Всегда 2, V2_PoseidonMerkle.
sequence number Монотонный ключ порядка в цепи. См. Порядок и инвариант sequence.
commitments string[] 32-байтный hex с 0x.
nullifiers string[] 32-байтный hex с 0x.
boundParamsHash string 32-байтный hex без префикса.
blockNumber number
txid string Хеш внешней транзакции, 32 байта без префикса.
utxoTreeIn / utxoTreeOut number Индексы входного и выходного UTXO-дерева.
utxoBatchStartPositionOut number Начало коммитментов в выходном дереве.
timestamp number Секунды Unix.
verificationHash string Накопительная цепь всех транзакций, 32 байта без префикса.
railgunTxid string poseidon(nullifiers, commitments, boundParamsHash), без префикса. Вычисляет индексатор, пустое значение пропускается.
unshield object Только у деэкранирований.

commitments.json.gz

Массив пакетных событий. Каждое объединяет коммитменты одного лога блокчейна на последовательных позициях от startPosition.

{
  "txid": "63459180506a7477924d1217245f967f22c32a2dbb0094c374a4d66eb236be5d",
  "treeNumber": 1,
  "startPosition": 35669,
  "blockNumber": 449432836,
  "commitments": [ /* 1+ commitment objects, see types below */ ]
}

Объект содержит commitmentType, txid, timestamp, hash, blockNumber, utxoTree, utxoIndex и поля типа:

ShieldCommitment (тип 2) — внесение в пул:

{
  "commitmentType": "ShieldCommitment",
  "txid": "…", "timestamp": 1775426303, "hash": "…", "blockNumber": 449432836, "utxoTree": 1, "utxoIndex": 35669,
  "preImage": { "npk": "…", "token": { "tokenAddress": "0x82aF…", "tokenType": 0, "tokenSubID": "0" }, "value": "0x…" },
  "encryptedBundle": ["…", "…", "…"],
  "shieldKey": "…",
  "fee": "10000000000000",
  "from": null
}

preImage.value имеет 0x; fee — десятичная строка, при нуле отсутствует; from всегда null.

TransactCommitmentV2 (тип 3) — выходная нота приватного перевода:

{
  "commitmentType": "TransactCommitmentV2",
  "txid": "…", "timestamp": 1775426303, "hash": "…", "blockNumber": 449432836, "utxoTree": 1, "utxoIndex": 35670,
  "ciphertext": {
    "ciphertext": { "iv": "…(16-byte hex)", "tag": "…(16-byte hex)", "data": ["…", "…"] },
    "blindedReceiverViewingKey": "…",
    "blindedSenderViewingKey": "…",
    "memo": "0x…",
    "annotationData": "…"
  },
  "railgunTxid": "2a79bc02…"
}

ciphertext.memo имеет 0x, а iv/tag/data[]/annotationData/ключи просмотра — нет. Без сопоставленной транзакции railgunTxid равен null.

LegacyGeneratedCommitment (тип 0) похож на ShieldCommitment, но использует encryptedRandom: [string, string] вместо encryptedBundle/shieldKey/fee/from.

LegacyEncryptedCommitment (тип 1) похож на TransactCommitmentV2, но использует ciphertext.ephemeralKeys: string[] и ciphertext.memo: string[]: массив, а не строку с 0x.

Старые типы встречаются лишь до V2 в Ethereum. Новые цепи содержат только ShieldCommitment и TransactCommitmentV2, но полный клиент должен читать все четыре.

nullifiers.json.gz

{
  "nullifier": "298937ed3be6af8bf16ef3e0a70212e1012ce3902a15bfe2f23eae05df3f27ec",
  "treeNumber": 1,
  "txid": "63459180506a7477924d1217245f967f22c32a2dbb0094c374a4d66eb236be5d",
  "blockNumber": 449432836
}

nullifier и txid — 32-байтный hex без префикса. Глобальная уникальность по nullifier.

unshields.json.gz

{
  "txid": "cb4293e3a81241ef8b6c48285e4860222d9c147da8583aa1d53a7117f230c368",
  "timestamp": 1676326168,
  "toAddress": "0x5aD95C537b002770a39dea342c4bb2b68B1497aA",
  "tokenType": 0,
  "tokenAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
  "tokenSubID": "0",
  "amount": "1000000000000000",
  "fee": "10000000000000",
  "blockNumber": 60674448,
  "eventLogIndex": 5,
  "railgunTxid": "2a79bc02…",
  "poisPerList": null
}

amount/fee/tokenSubID — десятичные строки. timestamp, eventLogIndex, railgunTxid, poisPerList допускают null. Уникальность по (txid, eventLogIndex). Сейчас poisPerList всегда null.

Чанки и tip

Форма элементов одинакова, отличаются метаданные и жизненный цикл.

Запечатанный чанк Tip
Путь chunks/{id}/ tip/
Метаданные chunkId, startBlock, endBlock, counts, files, sealedAt startBlock, endBlock, counts, files, generatedAt, без chunkId
Изменение Неизменяем после повышения Переписывается при каждом flush индексатора
Cache-Control Сначала max-age=300, затем max-age=31536000, immutable max-age=15
Обнаружение Наличие чанка id, latestSealedChunk Корневой tipChecksum

tip/meta.json:

{
  "formatVersion": 2,
  "startBlock": 449762846,
  "endBlock": 449765001,
  "counts": { "commitments": 412, "nullifiers": 388, "unshields": 21, "transactions": 140 },
  "files": {
    "commitments": { "checksum": "…", "size": 12345, "jsonSize": 45678 },
    "nullifiers": { "checksum": "…", "size": 9876, "jsonSize": 23456 },
    "unshields": { "checksum": "…", "size": 1234, "jsonSize": 4567 },
    "transactions": { "checksum": "…", "size": 5678, "jsonSize": 12345 }
  },
  "generatedAt": "2026-04-06T12:34:56Z"
}

Tip — незапечатанное текущее окно. При превышении около 6 000 событий индексатор вырезает старейшие примерно 3 000 по границе блоков в новый чанк. События переходят из tip/ в chunks/{id}/, сохраняя содержимое, включая sequence.

Порядок и инвариант sequence

Контракт потребителя TXID:

  • transactions отсортированы по (blockNumber, sequence) по возрастанию во всех файлах. Другие типы определяются содержимым: позицией, нуллификатором, (txid, logIndex). Не полагайтесь на порядок массива; коммитменты пересортируются по (treeNumber, startPosition).
  • sequence — метка порядка, не гарантированно плотный индекс. Исправления оставляют пропуски или повторы диапазонов. Дедупликация идёт по метке; пропуск допускается лишь после проверки хеша, не молчаливого пропуска листа.
  • Плоский индекс TXID (tree * TREE_MAX_ITEMS + index) определяется позицией добавления и может отличаться от sequence. Разделяйте координаты меток и индексов, включая проверенные TXID и длины деревьев; переводите их по реальному соотношению головы.
  • Курсор graphID — десятичная строка последней принятой sequence. Найдите чанк и проверьте голову, удалите принятые метки и добавьте проверенный суффикс по порядку. Пустой или старый нечисловой курсор означает холодный старт.

Снимки деревьев

Producer публикует упакованные деревья Меркла, а не события для перестроения. Это обычные CDN-объекты на общих независимых от кошелька путях.

Артефакты — бинарные файлы gzip:

  • chunks/{id}/tree-utxo-{n}.bin.gz и chunks/{id}/tree-txid-{n}.bin.gz — завершённое дерево, один раз записанное в завершившем его чанке и далее неизменяемое.
  • tip/tree-utxo-active.bin.gz и tip/tree-txid-active.bin.gz — текущее граничное дерево только запечатанных данных.

Реестр treeSnapshot в корне задаёт расположение и проверку:

{
  "sealedEndBlock": 25684485,
  "chunkId": 198,
  "treeDepth": 16,
  "finalized": {
    "utxo": [
      { "tree": 0, "chunkId": 53, "leafCount": 65536, "finalizedAtBlock": 21332114, "root": "02854cff…" },
      { "tree": 1, "chunkId": 106, "leafCount": 65535, "finalizedAtBlock": 23461913, "root": "23699538…" }
    ],
    "txid": [
      { "tree": 0, "chunkId": 107, "leafCount": 65536, "finalizedAtBlock": 23481977, "root": "21efb8c0…" }
    ]
  },
  "active": {
    "utxo": { "tree": 3, "leafCount": 54733, "root": "2885ae14…", "checksum": "8f68e1ac…", "bytes": 3504154 },
    "txid": { "tree": 1, "leafCount": 58775, "root": "108cf0ac…", "checksum": "fa4c125d…", "bytes": 3762975 }
  },
  "txidState": { "leafCount": 124311, "cursorSeq": 124310, "verificationHash": "30c7d5e1…" }
}
Поле Примечания
treeDepth Показатель ёмкости: 2treeDepth листьев. Управляется протоколом и меняется с уведомлением; читайте поле, не фиксируйте 16 или 65536.
finalized[].leafCount Конечное число листьев. Обычно 2treeDepth, но при нехватке места для пакета завершение раннее; UTXO-дерево 1 mainnet завершилось на 65 535.
finalized[].finalizedAtBlock Блок завершающего листа или более поздняя граница. Созданный позже кошелёк не владеет нотами дерева, поэтому можно пропустить данные листьев, загрузив узлы.
finalized[].root / active.root Hex из 64 символов без префикса; сравните с последней записью артефакта.
active.checksum SHA-256 переданных gzip-байтов.
txidState На границе снимка: общее число TXID-листьев, метка последнего запечатанного листа и хеш проверки. Метки могут пропускаться, позиции листьев порядковые.

leafCount не всегда степень двойки. cursorSeq — метка, не позиция; использование как индекса вызывает смещение.

Холодный и тёплый запуск

Сценарий Действия Типичное время
Холодный, первая установка Параллельно все чанки и tip; около 200 в Ethereum, меньше в L2 5–15 секунд
Тёплый, повторный запуск Проверка манифеста, загрузка только изменённого tip Менее 1 секунды
CDN недоступен Прогресс остановлен, показано здоровье индексатора и идут повторы —

Graph отключён при установке дайджестов. Недоступность CDN останавливает синхронизацию, не переключая её молча на медленные запросы.

Целостность

Суммы событий считаются по несжатому JSON, снимков — по переданным gzip-байтам. Корень в конце снимка сравнивается с реестром. Tip проверяется через tipChecksum; несовпадение formatVersion вызывает очистку и повторную загрузку.

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

Любой может скачать публичный артефакт, сверить сумму и сопоставить события с логами блокчейна или другим индексатором. Доступ к инфраструктуре Anon не нужен; от CDN требуется лишь доступность.

Архитектура producer

Работают две системы:

  • Генератор дайджестов: одноразовая TypeScript-задача получает полную историю upstream ограниченными раундами, запечатывает и загружает в R2. Запускается вручную для новой цепи.
  • Индексатор: постоянный Go-процесс начинает с CDN и строит tip по живым блокам. При примерно 6 000 событиях запечатывает новый чанк, повышает предыдущий до неизменяемого и публикует снимки завершённых деревьев. Загрузка R2 ограничена интервалом 15 секунд.

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

Приватность

Это механизм приватности, а не только скорости. Синхронизация кошелька даёт общее объяснение; здесь — протокольное.

Запросы независимы от содержимого кошелька. Пути digest.anon.inc задаются состоянием цепи: чанком, диапазоном, суммой tip, не ключом просмотра, адресом или нотами. Клиенты в одной позиции посылают побайтно одинаковые запросы независимо от активов. Нет фильтра кошелька, который сервер мог бы разобрать, записать, сохранить или раскрыть по требованию.

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

Единообразие — ограничение дизайна. Оно сохраняется лишь пока выбор не зависит от активов. Загрузка «только нужных» диапазонов восстановила бы запрос в иной форме. Границы задаются высотой блока, а range-запросы не выбирают части файлов.

Что остаётся видимым:

  • CDN видит IP, время, объём и TLS. Содержимое публично, но холодная и хвостовая синхронизации различаются: можно оценить последний синхронизированный блок IP, не принадлежность нот.
  • Head API достигает Anon и несёт курсор. ?fromBlock / ?fromSequence раскрывает отставание, не активы.
  • PPOI несёт производные кошелька. Ослеплённые коммитменты из ключа просмотра — настоящий запрос по кошельку. Прокси может кешировать ответы и параметры, создавая риски хранения и корреляции. См. Прокси PPOI. В отличие от общей истории, этот путь требует отдельного анализа.

VPN или Tor меняет наблюдателей, но не стирает время и объём. Маршрутизация не убирает данные кошелька из PPOI: нужны проверки кеша, хранения и доступа. В индексаторе с запросами раскрытие находится на уровне приложения, и сетевая анонимность сама его не устраняет.

Это не приватное извлечение информации. Запрос не скрыт криптографией: в нём нет фильтра кошелька, который надо скрывать. Цена — скачивание всего множества анонимности вместо выборки. Сжатие, чанки и CDN ограничивают стоимость одним объектом на точку, а не на пользователя, позволяя лучше масштабироваться.

Поддерживаемые сети

Дайджесты создаются для Ethereum (1), Arbitrum (42161), Polygon (137), BSC (56). Ethereum имеет крупнейший набор, L2 меньше и синхронизируются быстрее.