Синхронизация дайджестов
При первом запуске 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 существенно меньше.
Клиент
Функции передаются движку напрямую при инициализации, без глобальных хуков. При запросе событий:
-
Получить корень: один маленький запрос даёт число чанков, сумму tip и текущую позицию.
-
Скачать недостающее: чанки кешируются отдельно; запечатанные переиспользуются постоянно по факту наличия. Tip обновляется при смене
tipChecksum. При промахе сначала скачиваетсяmeta.json, затем параллельно файлы событий. -
Собрать: объединить в
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:
- Разбирает
latestGraphIDкак десятичную меткуsequence. Отсутствующий, пустой или старый нечисловой курсор означает начало до первой последовательности. - Находит чанк курсора, получает достаточно данных для пакета, включая текущую голову для проверки. При догоняющей синхронизации берёт полный
tip/, в рабочем режиме — недавние события по последовательности. - Оставляет
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
Каждый пятисекундный такт:
- Статус:
GET /api/v1/indexer/head/{chainId}/statusвозвращаетtipBlock,cdnBlock,reorgEpochи работоспособность. - Проверка реорганизации: рост
reorgEpochзапускает восстановление вместо обычной обработки. - Первый опрос: записывает
tipBlockиcdnBlockкак базу без загрузки событий. - Изменения: если
tipBlockпрежний, действий нет. - Фиксация границы: новый
tipBlockзаписывается сразу, исключая дублирующую работу параллельных запросов. - Дельта:
GET /api/v1/indexer/head/{chainId}/recentвозвращает события междуcdnBlockиtipBlockв JSON с base64; непустые передаются движку. - Расширение: если
/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 относительно прошлого опроса запускает восстановление. Значение сохраняется локально для обнаружения изменений, случившихся при закрытом клиенте.
Шаги восстановления
- Откат TXID:
clearLeavesForInvalidVerificationHash(txCount)удаляет затронутые листья, затемinvalidateTXOsCacheAllWallets()заставляет перечитать БД. - Очистка событий: удаляются локальные записи с
blockNumber > forkBlock. - Сброс SyncMeta: если
highestBlockбольшеforkBlock, он сбрасывается наforkBlock. - Повторная синхронизация: загрузка 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 меньше и синхронизируются быстрее.