# Источники каталогов

Пул не знает RPC сам: раз в час он выкачивает 12 публичных каталогов, склеивает их по сетям и проверяет каждый найденный адрес
(`docs/CHAINS.md`). Сбор идёт с основного IP сервера, это по замыслу (правило №1 касается рабочего трафика скриптов, `docs/RULES.md`).

## Все 12 источников

Порядок строк это порядок обработки (`SOURCE_ORDER` в `src/rpc_pool/sources/__init__.py`). Если два каталога по-разному называют одну сеть, название берётся у того, кто идёт раньше.
Столбец «ref» это то, что источник считает идентификатором сети (`ChainHint.ref`): по нему таблица не-EVM сетей привязывает каталог к сети (`source_refs`, `docs/CHAINS.md`).

| # | Имя (`RPC_POOL_DISABLED_SOURCES`) | URL | Формат | Что даёт | ref |
|---|---|---|---|---|---|
| 1 | `chainlist` | `https://chainlist.org/rpcs.json` | JSON, массив сетей | основа: EVM-сети, адреса `http(s)` и `ws(s)` вперемешку, `isTestnet`, метка приватности `tracking` | chain id |
| 2 | `chainid` | `https://chainid.network/chains.json` | JSON, массив | EVM-сети, адреса строками, статус сети. Страховка к Chainlist | chain id |
| 3 | `rpcinfo` | `https://github.com/drpcorg/rpc-info-data/raw/refs/heads/main/chains.generated.json` | JSON, группы с `networks` | EVM-сети и несколько не-EVM (Solana, Bitcoin, NEAR, Algorand, Cosmos-сети...) | chain id, у не-EVM slug |
| 4 | `layerzero` | `https://metadata.layerzero-api.com/v1/metadata` | JSON, объект по `chainKey` | EVM и не-EVM (Aptos, Solana, Tron, Stellar, Sui, Starknet, Initia) | `chainKey` |
| 5 | `publicnode` | `https://publicnode.com/` | HTML, JSON в `<script id="__NEXT_DATA__">` | адреса PublicNode, которых больше нет нигде (Solana, Sui, Starknet, Polkadot, Cosmos...), в каталоге много `wss` (см. «Особенности») | slug сети из хоста: `solana-testnet` у `solana-testnet-rpc.publicnode.com` |
| 6 | `rpcnode` | `https://api.rpcnode.io/api/v1` | REST JSON | EVM, Solana, Cosmos, Starknet, Substrate, Sui, XRPL; собирается по частям (ниже) | slug |
| 7 | `cosmos_directory` | `https://chains.cosmos.directory/` | JSON | Cosmos-сети: RPC, которые каталог сам считает здоровыми. Единственный нормальный источник Cosmos | `chain_id` |
| 8 | `hyperlane` | `https://raw.githubusercontent.com/hyperlane-xyz/hyperlane-registry/main/chains/metadata.yaml` | YAML | сети Hyperlane (EVM, Cosmos, Solana, Starknet) | ключ в YAML |
| 9 | `safe` | `https://safe-config.safe.global/api/v1/chains?limit=500` | JSON, `results[]` | EVM-сети Safe, маленькое дополнение | chain id |
| 10 | `lifi` | `https://li.quest/v1/chains?chainTypes=EVM,SVM,UTXO,MVM` | JSON, `chains[]` | EVM, Solana (SVM), Bitcoin (UTXO), Sui (MVM), маленькое дополнение | `key` сети |
| 11 | `superchain` | `https://raw.githubusercontent.com/ethereum-optimism/superchain-registry/main/chainList.json` | JSON, массив | официальные RPC сетей OP Stack, маленькое дополнение | chain id |
| 12 | `blockpi` | `https://public.blockpi.io/` | HTML | публичные адреса BlockPI с главной страницы, мало (по разведке около 15) | метка `span.chain-id` |

### Частота

Каждый collect (раз в час, `RPC_POOL_COLLECT_INTERVAL_MIN`) обходит **все** источники сразу, параллельно. Исключение одно: `rpcnode` обновляет детали сетей по очереди (раздел ниже).
Остальные каталоги нагрузки почти не создают: это один-два GET на источник в час. Запросы идут с заголовком `User-Agent` из `RPC_POOL_USER_AGENT`.

Как ведётся один запрос (`HttpFetcher`): таймаут 60 секунд; обрыв соединения или таймаут повторяются один раз через 2 секунды; ответ не `2xx` повторов не имеет.
Весь источник целиком (с повторами и ожиданиями) не может занимать больше 900 секунд.

### Что делает collect с каждым адресом

1. **Нормализация URL**: схема и хост в нижнем регистре, без стандартного порта (`:443`, `:80`), без завершающего `/`, без пробелов по краям.
   Транспорт определяется по схеме: `http(s)` это `http`, `ws(s)` это `ws`.
2. **Отбрасывание** (в статистике это `dropped_url`): шаблоны с `{`, `}`, `<`, `>` (`${API_KEY}` и подобные), адреса с `api_key=` или `apikey=` в query, адреса с `user:pass@`.
   Платных RPC и URL с ключом в шаблоне в пуле нет по определению. URL с длинным hex-ключом **в пути** оставляются: каталоги публикуют их сами. Такой URL помечается `embedded_key`, а в логах и ошибках ключ маскируется (`***`).
3. **WebSocket-адреса** (`ws://` и `wss://`) остаются, если их может проверить хоть один проверяльщик. Если экосистема известна на момент сбора, это проверяльщик самой экосистемы: WS умеют EVM и Substrate, у остальных (Cosmos, Solana, Sui...) WS-адрес отбрасывается. WebSocket у Solana это только подписки (PubSub): на `getGenesisHash` узел отвечает `Method not found`, проверить такой адрес нечем.
   Адрес с неизвестной экосистемой (PublicNode, BlockPI, часть RPC.info) остаётся тоже: он уходит в `pending`, а check пробует его по очереди `SNIFF_ORDER` (EVM, потом Cosmos). WS умеет только EVM; Cosmos отвечает `unsupported:transport`, не отправив ни одного запроса.
   Поэтому `wss`-адрес EVM-узла сам находит свою сеть (`eip155:<chain id>`), а `wss`-адрес не-EVM узла остаётся в `pending`, пока таблица не назовёт его экосистему через `source_refs`. Не определился за `RPC_POOL_DEAD_AFTER_H` часов: чистка его удаляет, а следующий collect приносит заново.
4. **Определение сети.** Источник дал chain id (EVM) или chain_id Cosmos: ключ сети известен сразу (`eip155:<id>`, `cosmos:<id>`). Иначе ref ищется в `source_refs` таблицы не-EVM сетей. Не нашёлся: адрес уходит в `pending`
   и сеть определит ближайший check по тому, что сам RPC о себе ответит (`docs/CHAINS.md`).
5. Уникальность по паре (сеть, URL). Адрес из нескольких каталогов остаётся одной записью с несколькими источниками (`sources` в ответе API).

Незашифрованные `http://` и `ws://` хранятся и проверяются, но в API по умолчанию не отдаются (`insecure=true`, `docs/API.md`).

### Что собрано: статистика источника

В `/v1/stats` (вид `collect`) на каждый источник приходит такой объект:

| Поле | Смысл |
|---|---|
| `fetched` | сколько адресов источник вернул |
| `dropped_url` | отброшено (шаблон, ключ в query, `user:pass@`, WS у известной экосистемы, чей проверяльщик не умеет WS) |
| `kept` | сохранено: `fetched = dropped_url + kept` |
| `resolved` | из сохранённых сеть известна сразу |
| `pending` | из сохранённых сеть определит check: `kept = resolved + pending` |
| `error` | `null`, если всё хорошо. `timeout`: источник не уложился в 900 с. `empty result`: ответил, но ни одного адреса (скорее всего, сменился формат). Иначе `Тип: текст` ошибки с замаскированными URL |

**Каждый источник изолирован.** Упал, сменил формат, завис или не смог записаться в базу один: ошибка попадает в его `error`, остальные работают, а его прежние адреса не теряются (они удаляются обычным правилом чистки, только когда умрут). Запись источника идёт одной транзакцией: если она сорвалась, откатывается целиком, и счётчики `kept`/`resolved`/`pending` остаются нулевыми.
Источник, который вернул пустой результат, ничего не записывает. Сломавшийся парсер заметен в `error`; смотри `GET /v1/stats` (`docs/API.md`).

## Особенности источников

Факты ниже взяты из разведки 2026-10-18 (Приложение A в `plans/001-rpc-pool-design.md`). Размеры меняются, это снимок.

- **Chainlist.** Основа, примерно 96% всех адресов (около 3 тысяч сетей, около 7,6 тысяч адресов). `http(s)` и `ws(s)` лежат в одном массиве `rpc[]` и различаются по схеме. Сети с `redFlags`, содержащими `reusedChainId`, пропускаются:
  у них один chain id делят разные сети, и проверка chain id не отличит одну от другой. Метка `tracking` (`none`, `limited`, `yes`) есть только здесь, она доступна в API фильтром `tracking=none`.
  Около 14 адресов шаблонные (отбрасываются), около 50 с hex-ключом в пути (остаются с пометкой).
- **ChainID.** Сети со `status: deprecated` пропускаются. Флага тестнета нет, `is_testnet` остаётся неизвестным (`null`), пока другой источник его не скажет. Адреса приходят строками, часть с завершающим `/`.
- **RPC.info (drpc).** GitHub отвечает редиректом `302` на `raw.githubusercontent.com` с `Content-Type: text/plain`, поэтому JSON читается независимо от типа содержимого. У не-EVM сетей `chainId: null`, их идентификатор это `slug`
  (экосистема неизвестна, пока таблица не скажет). Сети со `slug`, содержащим `beacon`, пропускаются: это API консенсус-слоя, не RPC. Сеть без `chainId` и без `slug` тоже пропускается.
- **LayerZero.** Объект `chainKey → сеть`, около 4,5 МБ. Экосистема берётся из `chainDetails.chainType` (`evm`, `aptos`, `solana`, `tron`, `stellar`, `sui`, `starknet`, `initia` это Cosmos). Chain id берётся **только** у `chainType: evm`
  (у 51 EVM-сети он `null`: адреса такой сети уходят в `pending`, и сеть определит проба `eth_chainId`). `is_testnet` это `environment != "mainnet"` (`testnet` и `sandbox` одинаково не продакшн).
- **PublicNode.** Каталог встроен в главную страницу как данные Next.js. Хэш-ключ внутри `props.pageProps.values` меняется при каждом деплое сайта, поэтому список сетей ищется **по форме** (массив объектов с `platforms[].endpoint`), а не по пути.
  Берутся только JSON-RPC-хосты вида `<slug>-rpc.publicnode.com` по `https`/`wss` (суффикс `:443` у Cosmos-записей допускается); gRPC, REST и Beacon (`-grpc`, `-rest`, `-beacon-api`) пропускаются.
  ref это `<slug>` из хоста, а не название со страницы: одно название объединяет несколько сетей (у `Bitcoin` mainnet, testnet и signet) или протоколов (у `TRON` REST, solidity и EVM JSON-RPC), а `source_refs` в таблице должен называть одну сеть. Название со страницы остаётся в `name`.
  Адреса остаются как на странице. Chain id нет, экосистема не указана: адрес определит проба (сначала EVM, потом Cosmos); `https`-адрес может оказаться любым из двух, а `wss`-адрес только EVM, потому что Cosmos не умеет WS (шаг 3 выше).
  Так `wss`-адреса PublicNode у EVM-сетей попадают в пул как `transport=ws`. Адреса других экосистем (Solana, Sui, Starknet, Substrate...), `wss` в их числе, ждут в `pending`,
  пока в таблице не появится `source_refs` с их slug. Сейчас привязаны Solana, Sui, Starknet, Tron (`tron`, `tron-solidity`), Bitcoin и форки (Dash, PIVX, Firo, DeFiChain), Substrate-сети (Polkadot, Kusama, Avail, Avail Turing, Analog); без привязки остаётся Bitcoin SV (`docs/CHAINS.md`, раздел 5).
  `/_next/data/*` не трогаем, это запрещено в `robots.txt`. Страница без `__NEXT_DATA__` или без списка сетей даёт ошибку источника.
- **BlockPI.** Главная страница: каждый адрес это блок `div.network-item` с `span.rpc-url` (адрес) и `span.chain-id` (метка: `1`, `8453`, `zetachain_7000-1`). Какой протокол у адреса, страница не говорит (есть и Cosmos LCD), экосистема неизвестна.
  Страница без единого блока даёт ошибку источника. Шаблонный адрес `https://<chain>.public.blockpi.network/v1/rpc/public` для большинства сетей отвечает `503`, берётся только то, что реально перечислено на странице.
  Числовая метка не всегда chain id EVM: у Movement (REST Aptos) это `126`, у Sui `167000`. Эти две метки привязаны в таблице через `source_refs`, остальные числовые определяет проба EVM.
  `167000` при этом chain id EVM у Taiko, поэтому эти две метки действуют только на собственном хосте сети (`sui.blockpi.network`, `movement.blockpi.network`). На другом хосте ref получает хост (`167000@taiko.blockpi.network`), и сеть определяет проба (`docs/CHAINS.md`, раздел 5).
- **Cosmos Directory.** Берётся `best_apis.rpc[].address` каждой сети, у которой есть `chain_id`. Ключ сети это `cosmos:<chain_id>`. Алиас это `chain_name`. Тестнет определяется по `network_type`.
- **Hyperlane.** YAML размером около 240 КБ, разбирается в отдельном потоке (PyYAML медленный, нельзя блокировать event loop). Из `rpcUrls[]` берётся только поле `http`; `grpcUrls`, `restUrls`, `webSocket` и `pagination` не RPC и игнорируются.
  `protocol`: `ethereum` это EVM, `cosmos` и `cosmosnative` это Cosmos, `sealevel` это Solana, `starknet` это Starknet, остальное неизвестно.
- **Safe.** Chain id приходят строкой. Берутся `publicRpcUri.value` и `rpcUri.value`; если это один URL, он учитывается один раз.
- **LI.FI.** Параметр `chainTypes=EVM,SVM,UTXO,MVM`: EVM, Solana, Bitcoin, Sui. Адреса берутся из `metamask.rpcUrls`. `is_testnet` это `not mainnet`.
- **Superchain.** Официальные RPC сетей OP Stack, самый простой формат: `chainId`, `name`, `rpc[]`.

### RPCNode: сбор по частям

У RPCNode нет выгрузки всех адресов одним запросом, есть список сетей и отдельная карточка на каждую (`/registry/chains/{slug}`). Лимит у них 120 запросов в минуту, мы держим 100 (`RPC_POOL_RPCNODE_REQ_PER_MIN`). Поэтому источник работает инкрементально:

- **Каждый collect** запрашивает список сетей (страницами по 500, это несколько запросов). Дёшево.
- **Карточка сети** (с адресами) запрашивается не для всех, а для: сетей, которых нет в кеше; сетей, у которых изменилось число адресов; и примерно 1/24 остальных, самых давно обновлённых (круговая ротация: полный проход растягивается на сутки).
  Только сети, обновлённые больше 30 минут назад, участвуют в ротации (повторный запуск подряд не тратит бюджет зря).
- Больше `RPC_POOL_RPCNODE_MAX_DETAILS_PER_RUN` (300) карточек за один прогон не запрашивается, запросы идут с паузой `60 / RPC_POOL_RPCNODE_REQ_PER_MIN` секунд (0,6 с при 100). Сначала новые, потом изменившиеся, потом по ротации.
  Первое заполнение кеша занимает `ceil(число сетей / 300)` часовых прогонов; чтобы быстрее, подними `RPC_POOL_RPCNODE_MAX_DETAILS_PER_RUN` (300 запросов это около трёх минут при 100 в минуту).
  Живой замер 2026-10-19: RPCNode перечисляет 2461 сеть, по умолчанию это 9 часовых прогонов. Пока кеш не полон, сетей, которые есть только у RPCNode (XRP Ledger), в пуле нет.
  Выше ~1300 не поднимай: весь источник ограничен 900 секундами, а прогон с 1200 карточками занял 781 с. Не уложится, и источник отдаст `timeout` вместо адресов (уже скачанные карточки останутся в кеше).
- Карточки лежат в таблице `rpcnode_cache` в базе. Источник отдаёт адреса **всего кеша**, а не только свежих карточек, поэтому прогон, обновивший мало, всё равно рапортует всё, что RPCNode перечисляет.
  Сеть, которой больше нет в полностью полученном списке, удаляется из кеша, и её адреса источник больше не отдаёт. Пустой список кеш не чистит: каталог, который вдруг не перечислил ни одной сети, скорее сломан.
  Сеть, которая в списке есть, но с нечитаемым числом адресов, остаётся в кеше как была.
- Ответ `429`: ждём `Retry-After` (по умолчанию 60 с, не больше 300 с) и повторяем ту же сеть; третий `429` подряд заканчивает обновление, прогон не падает.
  Не получившаяся карточка пропускается и повторится в следующий раз. Нечитаемая карточка в кеш не попадает.
- Адреса с `needs_key: true` отбрасываются. Собственные поля здоровья RPCNode (`outcome` и подобные) **не используются**: это одна давняя проверка из одного места, мы проверяем сами.
- Адрес с транспортом `ws_jsonrpc`, записанный как `http(s)://`, превращается в `ws(s)://`: схема это единственное место, где транспорт хранится.
- Экосистемы по `family`: `evm`, `solana`, `cosmos`, `starknet`, `substrate`, `sui`, `xrpl`; остальное неизвестно.

## Как отключить источник

Переменная `RPC_POOL_DISABLED_SOURCES` со списком имён из таблицы через запятую (пробелы по краям игнорируются):

```bash
RPC_POOL_DISABLED_SOURCES=blockpi,rpcnode uv run rpc-pool serve
```

или строкой в `.env` (`uv run --env-file .env rpc-pool serve`).

- Неизвестное имя игнорируется молча. Опечатку видно по `/v1/stats`: если источник должен быть отключён, а в `collect.stats` он есть, имя написано неверно.
- Отключение влияет на следующий collect (перезапусти `serve`). Уже собранные адреса остаются в базе и живут по обычным правилам чистки: пока отвечают, остаются в выдаче, умерли, удаляются через сутки.
- Отключённый источник не нагружает чужой сервер ни в `serve`, ни в `collect`, ни в `once`.

## Как добавить новый источник

Источник это класс с атрибутом `name` и методом `async fetch(http, now) -> list[RawEndpoint]` (протокол `Source` в `src/rpc_pool/sources/base.py`).
Он скачивает каталог и возвращает все адреса **как есть**: нормализует, отбрасывает и привязывает к сетям потом collect. Шаги:

1. **Модуль** `src/rpc_pool/sources/<имя>.py` с константами `NAME`, `URL`, чистой функцией `parse_<имя>(data)` и классом `<Имя>Source`. Парсер должен только разбирать готовые данные (без сети), чтобы его можно было проверить на фикстуре.
   Образцы: `chainlist.py` (JSON), `hyperlane.py` (YAML, разбор в `asyncio.to_thread`), `publicnode.py` и `blockpi.py` (HTML), `rpcnode.py` (REST со своим кешем).
2. **Скачивание** только через `http.get_json(url, params=...)` или `http.get_text(url)` (`HttpFetcher`: общая сессия, таймаут, повтор, `User-Agent`). Свою `ClientSession` не создавай, любой сетевой вызов внутри `asyncio.timeout`.
3. **Парсинг устойчив к чужим данным**: элемент неправильного типа пропускается, а не роняет весь разбор. Для этого есть помощники `as_dict`, `as_list`, `as_str`, `as_bool`, `as_chain_id`, `str_items`, `aliases`.
   Только неверная форма всего ответа это ошибка: `top_level_list` и `top_level_dict` поднимают `SourceError`, и тогда ошибка попадёт в статистику источника.
4. **Подсказка о сети**: на каждый адрес `RawEndpoint(source, url, hint)` с `ChainHint(source, ref, name, ecosystem, evm_chain_id, cosmos_chain_id, is_testnet, aliases)`.
   - Для EVM укажи `ecosystem=Ecosystem.EVM` и `evm_chain_id`, для Cosmos `ecosystem=Ecosystem.COSMOS` и `cosmos_chain_id`: тогда ключ сети известен сразу.
   - Нет chain id: `ecosystem=Ecosystem.UNKNOWN` (если каталог не говорит) и осмысленный `ref`. Не-EVM сеть привяжется через `source_refs` в таблице; экосистемы «неизвестно» без этого определяются только пробой EVM и Cosmos.
   - `is_testnet` пиши только если каталог это говорит, иначе `None`.
5. **Регистрация** в `src/rpc_pool/sources/__init__.py`: импорт, имя в `SOURCE_ORDER` (порядок = приоритет названия сети) и фабрика в `_FACTORIES` (`lambda db, settings: НовыйSource()`). Имя должно совпадать с `name` класса и с тем, что принимает `RPC_POOL_DISABLED_SOURCES`.
6. **Тесты и фикстура**: урезанный настоящий ответ каталога в `tests/fixtures/<имя>.json` (или `.html`, `.yaml`), проверки парсера в `tests/test_sources_*.py` (по образцу соседних) и проверка с локальным фейковым HTTP-сервером для `fetch`.
   Нужна новая зависимость: остановись и спроси владельца (`uv add` только с его ведома).
7. **Не-EVM привязки**: если источник приносит не-EVM сети, добавь в `src/rpc_pool/data/chains_nonevm.yaml` его `ref` в `source_refs` соответствующей сети (`docs/CHAINS.md`) и в тест `tests/test_registry.py`, если проверяются привязки.
8. **Живая проверка**: `uv run rpc-pool --db data/dev.db collect` печатает статистику по каждому источнику в JSON. Смотри `fetched`, `dropped_url`, `error`.
9. **Документация**: строка в таблице выше и абзац в «Особенностях».

Quality gate перед тем, как считать готовым: `uv run ruff check --fix . && uv run ruff format . && uv run mypy --strict . && uv run pytest -q`.
