# Сети: ключи, алиасы, identity и как добавить новую

Главная защита пула от путаницы сетей: сеть определяется не по названию в каталоге, а по тому, **что сам RPC о себе отвечает** (identity).
Каталоги ошибаются и путаются в названиях, узел свою сеть знает. Для EVM и Cosmos identity и есть ключ сети, склейка идёт автоматически.
Для остальных экосистем в репозитории лежит небольшая ручная таблица `src/rpc_pool/data/chains_nonevm.yaml`.
Сеть, которой в таблице нет, пул заводит сам под ключом `<экосистема>:x-<slug>` (раздел 9): новая сеть из каталогов отдаётся без правки таблицы.

Коротко, как дать не-EVM сети красивый ключ из таблицы: узнать её identity (у автосети лежит в базе, раздел 9; у остальных в `/v1/unmapped`), добавить запись в YAML, обновить `EXPECTED_IDENTITIES` в `tests/test_registry.py`, перезапустить `serve` (раздел 6).
Сеть новой экосистемы без проверяльщика: сначала добавь проверяльщик (раздел 7).

## 1. Формат ключа сети

Ключ это стабильное имя сети в API (`chain.key`, `GET /v1/chains/{chain}`).

| Экосистема | Ключ | Примеры | Откуда берётся |
|---|---|---|---|
| EVM | `eip155:<chainId>` (CAIP-2) | `eip155:1`, `eip155:8453` | автоматически: chain id из каталога, потом подтверждается `eth_chainId` |
| Cosmos | `cosmos:<chain_id>` | `cosmos:osmosis-1`, `cosmos:cosmoshub-4` | автоматически: `chain_id` каталога, подтверждается `node_info.network` |
| остальные | `<имя>:<сеть>` | `solana:mainnet`, `sui:testnet`, `aptos:mainnet`, `bitcoin:testnet` | из таблицы `chains_nonevm.yaml` |
| автосеть (остальные экосистемы) | `<экосистема>:x-<slug>` | `sui:x-0f1e2d3c4b5a6978` | автоматически: identity, которой нет в таблице (раздел 9) |

Ключ из таблицы не обязан начинаться с имени экосистемы: у `movement:mainnet` экосистема `aptos` (форк Aptos, тот же проверяльщик).
Ключи уникальны. Название и флаг `is_testnet` у EVM и Cosmos берутся из каталогов: название у того источника, кто назвал сеть первым (порядок: `docs/SOURCES.md`),
флаг у первого, кто его сказал. У сетей из таблицы название и флаг берутся из таблицы. `is_testnet: null` значит «никто не сказал»; фильтр `testnet=` такие сети не показывает (`docs/API.md`).

## 2. Алиасы и порядок разрешения

API принимает вместо ключа что угодно из списка ниже: chain id (`/v1/chains/1/rpcs`), ключ (`/v1/chains/eip155:8453/rpcs`) и, для рук, алиас (`base`, `solana`). В скриптах используй chain id или ключ: алиас может стать неоднозначным (ниже, «Конфликт»). Функция `resolve_alias` проверяет по порядку и берёт первое совпадение:

1. **Точный ключ сети** (с учётом регистра): `eip155:8453`, `solana:mainnet`.
2. **Число** (только ASCII-цифры): EVM chain id, `8453` это `eip155:8453`. Ведущие нули не мешают (`08453`). Работает, только если такая сеть уже есть в базе.
3. **Алиас** (без учёта регистра, всё приводится к нижнему): `base`, `Base`, `sol`.
4. **Cosmos без префикса**: `osmosis-1` это `cosmos:osmosis-1`.

Не нашлось: `404 unknown_chain` с подсказками (до пяти ближайших ключей и алиасов, `docs/API.md`). Пробелы по краям отбрасываются.

Откуда берутся алиасы:

| Источник | Алиас |
|---|---|
| Chainlist | `shortName` и `chainSlug` |
| ChainID, Safe | `shortName` |
| RPC.info | `slug` |
| LayerZero | `chainKey` (только у EVM) |
| RPCNode | `slug` (только у EVM) |
| Hyperlane | ключ в YAML (только у EVM) |
| Cosmos Directory | `chain_name` |
| таблица `chains_nonevm.yaml` | поле `aliases` |
| промоушен автосети в таблицу | её прежний ключ `<экосистема>:x-<slug>` (источник `auto`, раздел 9) |

Остальные источники алиасов не дают. Правила: алиас приводится к нижнему регистру и обрезается; пустые и состоящие только из цифр не регистрируются (число и так однозначно: это chain id).

**Конфликт.** Если один алиас заявляют две разные сети из каталогов, он становится неоднозначным навсегда: не работает ни для одной (`404`) и не попадает в подсказки.
**Алиасы таблицы старше каталогов.** Алиас из `chains_nonevm.yaml` всегда принадлежит сети таблицы: он отбирает имя у сети каталога, снимает неоднозначность, и ни один каталог не может его забрать.
Так `bitcoin`, `btc`, `near` и `tron` ведут на `bitcoin:mainnet`, `near:mainnet` и `tron:mainnet`, хотя в Chainlist есть EVM-сети с такими `shortName` (8086, 2203, 397, 728126428).
Так же `polkadot`, `dot`, `kusama` и `ksm` ведут на relay-цепи `polkadot:mainnet` и `kusama:mainnet`, а EVM-сети Polkadot Hub (`420420419`) и Kusama Hub (`420420418`) доступны по chain id и алиасам `polkadot-420420419`, `kusama-420420418`.
Числовой chain id однозначен всегда, поэтому для кода надёжнее ключ или chain id; алиасы это удобство для рук. Свои алиасы в таблице выбирай осознанно: совпавший алиас каталога его сеть потеряет.

## 3. Как сеть определяется: identity-first

```
каталог дал URL + подсказку о сети (chain id / chain_id / название)
        |
        v
 collect: ключ известен сразу?  ──да──►  endpoint в сети (статус new), ждёт проверки
        |нет (нет chain id)
        v
     pending  (URL ждёт в очереди)
        |
        v
 check (полный, без --chains): спросить сам RPC, кто он
        |
        +── ответил, identity найдена ─────────►  endpoint в нужной сети
        +── ответил, identity нет в таблице ───►  endpoint в автосети <экосистема>:x-<slug> (раздел 9)
        +── не ответил ─────────────────────────►  остаётся в pending до следующего check
```

**Что знает collect.** Подсказка источника (`ChainHint`) даёт ключ сети, если в ней есть chain id EVM (`eip155:<id>`) или chain_id Cosmos (`cosmos:<id>`), либо если её пара (источник, ref) записана в `source_refs` таблицы.
Иначе адрес уходит в `pending`, кроме адреса, который уже лежит в endpoints автосети той же экосистемы: его collect обновляет там сразу (раздел 9). Экосистему подсказки берут из каталога (`chainType`, `family`, `protocol`...) или из таблицы по `source_refs`; если не знает никто, она «неизвестна».

**Что делает check с `pending`.** Работает только полный check (`rpc-pool check` без `--chains`, плановый цикл; `once` и `check --chains` `pending` не трогают).

- Экосистема известна: адрес проверяет проверяльщик этой экосистемы. Identity даёт ключ сети (для EVM и Cosmos всегда; для остальных экосистем ключ из таблицы, а если identity в таблице нет, то ключ автосети, раздел 9): адрес переезжает в `endpoints` этой сети.
  Ключ не собрать, только когда в identity нет ни одного символа `[a-z0-9]`: строка в `unmapped` с `reason: no_table_entry`.
- Экосистема неизвестна: пробуются **EVM, потом Cosmos** (`SNIFF_ORDER`), побеждает первый успешный ответ. Из EVM-ответа получается `eip155:<chainId>`, из Cosmos-ответа `cosmos:<network>`.
  У `ws://` и `wss://` адреса (так приходят адреса PublicNode и BlockPI) отвечать может только EVM: проверяльщик Cosmos не умеет WS и сразу отвечает `fail` с `unsupported:transport`, не отправляя запрос.
  EVM-узел по WS определяется как обычный, и его адрес переезжает в `endpoints` как `transport=ws`.
  Другие экосистемы так не определить (протокол незнакомого узла не угадывается): их адрес остаётся в `pending`, пока `source_refs` не скажет экосистему. Неопределённый `pending` старше 24 часов (`RPC_POOL_DEAD_AFTER_H`) чистка удаляет.
  Каталог при следующем collect принесёт его заново.
- Ни один проверяльщик не получил ответа: адрес остаётся в `pending` до следующего check.
- Для экосистемы вообще нет проверяльщика: строка в `unmapped` с `reason: no_checker`. С 13 поставляемыми экосистемами такого не случается, причина появится, если добавить значение `Ecosystem` без проверяльщика.

**Что делает check с обычным endpoint.** Каждый адрес проверяется проверяльщиком экосистемы своей сети: identity сравнивается с ожидаемыми значениями сети (точное совпадение, раздел 5), высота сравнивается с эталоном сети.

| Итог | Условие | В выдаче? |
|---|---|---|
| `ok` | identity совпала, высота получена, не отстаёт | да |
| `lagging` | identity верна, но высота отстаёт от эталона больше порога | нет |
| `wrong_chain` | identity не из ожидаемых этой сети | нет |
| `fail` | таймаут, обрыв, HTTP-ошибка, не JSON, JSON-RPC error | нет |
| `inconclusive` | `429` или rate limit; статус не меняется, 3 подряд считаются как `fail` | не меняется |

**`wrong_chain` и переезд.** Если узел назвал identity, которая принадлежит **другой известной сети** той же экосистемы (например, URL лежал в каталоге под одним chain id, а ответил другим),
пара (другая сеть, URL) создаётся как новый кандидат со статусом `new` и проверяется на следующем check. Так URL, попавший в каталоге не в ту сеть, сам находит свою.
Сама пара (старая сеть, URL) становится `sick` и удаляется чисткой, если за сутки не получит `ok`.

## 4. Что спрашивает проверка: таблица по экосистемам

Каждая проба это один-два запроса. **Identity** определяет сеть, **высота** нужна для оценки отставания. Время ответа (`latency_ms`) это длительность первого, identity-запроса.
Таймаут пробы `RPC_POOL_PROBE_TIMEOUT_S`, не больше `RPC_POOL_PER_HOST_LIMIT` запросов одновременно на одного провайдера (считается по двум последним меткам домена: `eth.drpc.org` и `base.drpc.org` делят один лимит).

| Экосистема | Транспорт | Identity (запрос и поле) | Высота | Как записана identity |
|---|---|---|---|---|
| `evm` | http, ws | `eth_chainId` | `eth_blockNumber` | десятичный chain id строкой: `"8453"` |
| `cosmos` | http | `GET <url>/status`, `result.node_info.network` | там же, `sync_info.latest_block_height` | как есть: `osmosis-1` |
| `solana` | http | `getGenesisHash` | `getSlot` | полный genesis hash в base58, 44 символа |
| `sui` | http | `sui_getChainIdentifier` | `sui_getLatestCheckpointSequenceNumber` | как есть: `35834a8a` |
| `aptos` | http | `GET <url>/v1`, поле `chain_id` (если путь уже кончается на `/v1`, не дублируется) | там же, `block_height` | десятичная строка: `"1"` |
| `starknet` | http | `starknet_chainId` | `starknet_blockNumber` | нормализуется: `0x`, нижний регистр, без ведущих нулей (`0x534e5f4d41494e`) |
| `near` | http | JSON-RPC `status`, поле `chain_id` | там же, `sync_info.latest_block_height` | как есть: `mainnet` |
| `substrate` | http, ws | `chain_getBlockHash` с аргументом `0` (genesis) | `chain_getHeader`, поле `number` | `0x` + 64 hex в нижнем регистре |
| `tron` | http | REST: `POST <url>/wallet/getblockbynum` `{"num": 0}`, поле `blockID`. Если путь URL кончается на `/jsonrpc`: `eth_chainId` | REST: `POST <url>/wallet/getnowblock`, `block_header.raw_data.number`. JSON-RPC: `eth_blockNumber` | REST: 64 hex без префикса в нижнем регистре. `/jsonrpc`: десятичный chain id (`728126428`) |
| `stellar` | http | `getNetwork`, поле `passphrase` (Stellar RPC, бывший Soroban RPC; запрос без `params`: `"params": []` узел отвергает) | `getHealth`, поле `latestLedger` (`getLatestLedger` на mainnet больше 1 МиБ) | passphrase как есть, с пробелами и точкой с запятой |
| `xrpl` | http | `server_info` в формате rippled (`{"method": "server_info", "params": [{}]}`), `result.info.network_id` | `result.info.validated_ledger.seq` | десятичная строка; если узел не называет `network_id` (так делает mainnet), то `"0"` |
| `algorand` | http | `GET <url>/v2/transactions/params`, поле `genesis-id` | там же, `last-round` | как есть: `mainnet-v1.0` |
| `bitcoin` | http | `getblockhash` с аргументом `0` (JSON-RPC 1.0): хэш нулевого блока. Поле `chain` из `getblockchaininfo` не годится: форки (Zcash, Dash, Litecoin...) тоже называют свою сеть `main` | `getblockcount` | 64 hex без префикса в нижнем регистре. Сплиты с общей историей (Bitcoin Cash, Bitcoin SV) делят genesis с Bitcoin и так не отличаются |

- Ответ не по форме (поле не число, identity не текст, длиннее 128 символов или с непечатными символами, тело больше 1 МиБ) это `fail` (`bad_json`), а не «неизвестная сеть».
- WebSocket-пробы (`transport=ws`): подключиться, отправить один identity-запрос, закрыть. WS есть только у `evm` и `substrate`. У Solana WebSocket это только подписки (PubSub), `getGenesisHash` по нему не ответит, поэтому `wss`-адреса Solana collect отбрасывает.
  Адрес с неизвестной экосистемой тоже может быть `wss`: в `pending` его пробует `evm` (раздел 3), остальные экосистемы из `SNIFF_ORDER` без WS его пропускают.
- У Tron два способа записи identity у одной сети, оба перечислены в таблице (раздел 5).
- Пробы ходят **только на публичные адреса**. Loopback, частные сети, link-local (там же metadata облака), CGNAT, multicast и прочие непубличные адреса отвергаются в момент подключения, уже после DNS: и IP прямо в URL, и имя, которое резолвится в такой адрес.
  Итог `fail` с ошибкой `conn:blocked_address`, запрос не уходит. Зачем: каталоги перечисляют адреса вроде `http://127.0.0.1:8545`, и проба не должна попасть на сам сервер, его локальную сеть или metadata облака.
- По редиректам пробы не ходят, ни HTTP, ни рукопожатие WebSocket: ответ `3xx` это `fail` с ошибкой `http:http_302` (и так далее по коду).

## 5. Таблица не-EVM сетей: `src/rpc_pool/data/chains_nonevm.yaml`

EVM и Cosmos в таблицу не вносятся (запись для них отвергается). Файл это YAML-список, одна сеть на запись:

```yaml
- key: solana:mainnet
  ecosystem: solana
  name: Solana Mainnet
  is_testnet: false
  identities: ["5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"]
  aliases: [solana, sol]
  source_refs: {rpcinfo: [solana], layerzero: [solana], lifi: [sol]}
```

| Поле | Обязательное | Смысл |
|---|---|---|
| `key` | да | ключ сети, `<имя>:<сеть>`, уникальный. Строка |
| `ecosystem` | да | одна из экосистем: `solana`, `sui`, `aptos`, `starknet`, `substrate`, `near`, `tron`, `stellar`, `xrpl`, `algorand`, `bitcoin`. Не `evm`, не `cosmos`, не `unknown` |
| `name` | да | отображаемое название. Непустая строка |
| `is_testnet` | да | `true` или `false` (булево YAML, не строка) |
| `identities` | да | непустой список identity, которые узел этой сети может назвать. **Всегда в кавычках** |
| `aliases` | нет | алиасы строками (нижний регистр, не только цифры, не совпадают с алиасами других сетей) |
| `source_refs` | нет | словарь `имя источника → список ref`, ref это идентификатор сети у этого источника (столбец «ref» в `docs/SOURCES.md`). Сопоставление без учёта регистра. Может быть `{}` |

**Что проверяет загрузчик `load_table`** (ошибка называет файл и номер записи; сервер с неверной таблицей не стартует, команда завершается ошибкой `command failed`):

- файл это YAML-список записей-словарей; в записи есть все обязательные поля и нет никаких, кроме перечисленных;
- `key` и `name` непустые строки, `ecosystem` из допустимых, `is_testnet` булево;
- `identities` непустой список непустых **строк** (число или значение, которое YAML разобрал как число, отвергается);
- `aliases` и каждый список в `source_refs` это списки непустых строк;
- уникальны: `key`; пара (`ecosystem`, identity) (одна и та же identity не может принадлежать двум сетям); пара (источник, ref в нижнем регистре).

Уникальность и формат **алиасов** загрузчик не проверяет, это делает тест `test_packaged_table_aliases_are_unique_lowercase_and_not_numeric`.

**Почему identity всегда в кавычках.** Без кавычек YAML разберёт `1`, `0`, `126` и даже `0x1f` как число, и загрузчик откажет. С кавычками это строки.

**Сравнение identity точное и с учётом регистра.** Загрузчик и реестр сравнивают строки как есть, ничего не приводя. Нормализует проверяльщик (колонка «Как записана identity» в разделе 4):
Starknet приводится к нижнему регистру без ведущих нулей, Substrate, Tron и Bitcoin к нижнему регистру, EVM к десятичному. Поэтому **в таблице identity нужно писать ровно в том виде, как её отдаёт проверяльщик**, а не как в документации сети.
Solana это полный genesis hash из 44 символов base58 (регистр важен, обрезать нельзя).

### Что в таблице сейчас

43 сети. Каждая identity прочитана с живых узлов 2026-10-19 (по возможности на двух независимых провайдерах), кроме `sui:testnet` и `algorand:testnet`: их адресов в каталогах нет, identity взяты из документации сетей.
И кроме `vara:mainnet` и `vara:testnet`: их единственные адреса (dRPC) отвечают на пробу `400`, identity взяты из списков сетей `@polkadot/networks` (mainnet) и SubWallet (обе). Identity Substrate-сетей, прочитанные живьём, совпадают с genesis-хэшами в списке SubWallet; в таблице ниже они сокращены (полностью: `chains_nonevm.yaml`).
Столбец «ref» это `source_refs` записи: по ним collect сразу кладёт адрес в нужную сеть.

| Ключ | Экосистема | Testnet | Identity | Алиасы | ref источников |
|---|---|---|---|---|---|
| `solana:mainnet` | solana | нет | `5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d` | `solana`, `sol` | rpcinfo `solana`, layerzero `solana`, lifi `sol`, hyperlane `solanamainnet`, rpcnode `solana-mainnet`, publicnode `solana` |
| `solana:devnet` | solana | да | `EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG` | `solana-devnet` | hyperlane `solanadevnet`, rpcnode `solana-devnet`, layerzero `solana-testnet` (это devnet) |
| `solana:testnet` | solana | да | `4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY` | `solana-testnet` | hyperlane `solanatestnet`, rpcnode `solana-testnet`, publicnode `solana-testnet` |
| `eclipse:mainnet` | solana | нет | `EAQLJCV2mh23BsK2P9oYpV5CHVLDNHTxYss3URrNmg3s` | `eclipse-mainnet` | hyperlane `eclipsemainnet` |
| `eclipse:testnet` | solana | да | `CX4huckiV9QNAkKNVKi5Tj8nxzBive5kQimd94viMKsU` | нет | hyperlane `eclipsetestnet` |
| `sonicsvm:mainnet` | solana | нет | `9qoRTAHGWBZHYzMJGkt62wBbFRASj6H7CvoNsNyRw2h4` | `sonicsvm` | hyperlane `sonicsvm` |
| `sonicsvm:testnet` | solana | да | `BRSVhduW2sGkyhrxK6MYqyymirEggFv1hQh8jPqdYBLH` | `sonicsvm-testnet` | hyperlane `sonicsvmtestnet` |
| `soon:mainnet` | solana | нет | `E8aYS7Vghmf1sZVSsCse9JdFHzccdE9QdpPF5SVNcGxr` | `soon` | hyperlane `soon` |
| `svmbnb:mainnet` | solana | нет | `8MCzWLHk3FmrdW1gVtZe7NgDefMhYFZfTUmvMANn5r6X` | `svmbnb` | hyperlane `svmbnb` |
| `solaxy:mainnet` | solana | нет | `4LJggBvCsZ3j6Mwkg67yfaM4uW3AFbUwgJJeWY8MrHsH` | `solaxy` | hyperlane `solaxy` |
| `nara:mainnet` | solana | нет | `6J5VuMUrPexVouKHi6omFPh4NYCEWHS6igkwKhCHbG87` | `nara` | hyperlane `nara` |
| `sui:mainnet` | sui | нет | `35834a8a` | `sui` | lifi `sui`, rpcnode `sui`, publicnode `sui`, blockpi `167000` (только на `sui.blockpi.network`: это ещё и chain id Taiko) |
| `sui:testnet` | sui | да | `4c78adac` | `sui-testnet` | нет |
| `aptos:mainnet` | aptos | нет | `1` | `aptos` | layerzero `aptos` |
| `aptos:testnet` | aptos | да | `2` | `aptos-testnet` | layerzero `aptos-testnet` |
| `movement:mainnet` | aptos | нет | `126` | `movement` | blockpi `126` (только на `movement.blockpi.network`) |
| `starknet:mainnet` | starknet | нет | `0x534e5f4d41494e` | `starknet` | hyperlane `starknet`, rpcnode `starknet`, publicnode `starknet` |
| `starknet:sepolia` | starknet | да | `0x534e5f5345504f4c4941` | `starknet-sepolia` | hyperlane `starknetsepolia`, publicnode `starknet-sepolia` |
| `paradex:mainnet` | starknet | нет | `0x505249564154455f534e5f50415241434c4541525f4d41494e4e4554` (`PRIVATE_SN_PARACLEAR_MAINNET`) | `paradex` | hyperlane `paradex` |
| `near:mainnet` | near | нет | `mainnet` | `near` | rpcinfo `near` |
| `near:testnet` | near | да | `testnet` | `near-testnet` | rpcinfo `near-testnet` |
| `tron:mainnet` | tron | нет | два значения: id нулевого блока (64 hex, начинается с `00000000000000001ebf8850`) и `728126428` | `tron`, `trx` | layerzero `tron`, hyperlane `tron`, publicnode `tron`, `tron-solidity` (SolidityNode, ниже) |
| `tron:shasta` | tron | да | `2494104990` (chain id `/jsonrpc`; REST-адресов Shasta в каталогах нет) | `tron-shasta` | layerzero `tron-testnet`, hyperlane `tronshasta` |
| `stellar:mainnet` | stellar | нет | `Public Global Stellar Network ; September 2015` | `stellar`, `xlm` | layerzero `stellar` |
| `stellar:testnet` | stellar | да | `Test SDF Network ; September 2015` | `stellar-testnet` | layerzero `stellar-testnet` |
| `xrpl:mainnet` | xrpl | нет | `0` | `xrpl`, `xrp` | rpcnode `xrp-ledger` |
| `algorand:mainnet` | algorand | нет | `mainnet-v1.0` | `algorand`, `algo` | rpcinfo `algorand` |
| `algorand:testnet` | algorand | да | `testnet-v1.0` | `algorand-testnet` | нет |
| `polkadot:mainnet` | substrate | нет | хэш блока 0, `0x91b171bb158e2d38…` (прочитан у PublicNode, OnFinality и rpc.polkadot.io) | `polkadot`, `dot` | rpcnode `polkadot`, publicnode `polkadot` |
| `kusama:mainnet` | substrate | нет | `0xb0a8d493285c2df7…` | `kusama`, `ksm` | publicnode `kusama` |
| `avail:mainnet` | substrate | нет | `0xb91746b45e0346cc…` | `avail` | publicnode `avail` |
| `avail:turing` | substrate | да | `0xd3d2f3a3495dc597…` | `avail-turing` | publicnode `avail-turing` |
| `analog:mainnet` | substrate | нет | `0x1459b0204b92719f…` | `analog` | publicnode `analog` |
| `vara:mainnet` | substrate | нет | `0xfe1b4c55fd4d6681…` (из списков сетей, не с узла) | `vara` | rpcinfo `vara` |
| `vara:testnet` | substrate | да | `0x525639f713f397dc…` (из списка SubWallet, не с узла) | `vara-testnet` | rpcinfo `vara-testnet` |
| `bitcoin:mainnet` | bitcoin | нет | хэш блока 0, `000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f` | `bitcoin`, `btc` | rpcinfo `bitcoin`, lifi `btc`, publicnode `bitcoin` |
| `bitcoin:testnet` | bitcoin | да | хэш блока 0 testnet3, `000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943` | `bitcoin-testnet` | rpcinfo `bitcoin-testnet`, publicnode `bitcoin-testnet` |
| `bitcoin:signet` | bitcoin | да | `00000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6` | `bitcoin-signet` | publicnode `bitcoin-signet` |
| `zcash:mainnet` | bitcoin | нет | `00040fe8ec8471911baa1db1266ea15dd06b4a8a5c453883c000b031973dce08` | `zcash`, `zec` | lifi `zec` |
| `dash:mainnet` | bitcoin | нет | `00000ffd590b1485b3caadc19b22e6379c733355108f107a430458cdf3407ab6` | `dash` | publicnode `dash` |
| `pivx:mainnet` | bitcoin | нет | `0000041e482b9b9691d98eefb48473405c0b8ec31b76df3797c74a78680ef818` | `pivx` | publicnode `pivx` |
| `firo:mainnet` | bitcoin | нет | `4381deb85b1b2c9843c222944b616d997516dcbd6a964e1eaf0def0830695233` | `firo` | publicnode `firo` |
| `defichain:mainnet` | bitcoin | нет | `279b1a87aedc7b9471d4ad4e5f12967ab6259926cd097ade188dfcf22ebfe72a` | `defichain` | publicnode `defichain` |

Bitcoin-форки (Zcash, Dash, PIVX, Firo, DeFiChain) живут в экосистеме `bitcoin`: тот же проверяльщик, отличаются хэшем нулевого блока. Ключ не обязан начинаться с имени экосистемы (раздел 1).

**Особые привязки:**

- **PublicNode `tron-solidity`** это не обычный узел, а **SolidityNode**: его API `/walletsolidity/*` отдаёт только подтверждённые блоки. Сеть та же, `tron:mainnet`.
  Проба спрашивает `/wallet/*`, и на живом прогоне 2026-10-19 этот хост ответил и на них, с той же высотой, что и обычный узел PublicNode. Если скрипту нужны неподтверждённые данные, учитывай, что адрес может оказаться SolidityNode.
- **Числовые метки BlockPI.** `167000` у BlockPI это Sui, но это и chain id EVM у Taiko; `126` это Movement. Парсер BlockPI считает такую метку меткой Sui или Movement только на их собственном хосте (`sui.blockpi.network`, `movement.blockpi.network`).
  Адрес с той же меткой на другом хосте получает ref вида `167000@taiko.blockpi.network`, таблица его не знает, и сеть определяет проба (для Taiko это `eip155:167000`). Новую такую метку добавляй в `_NON_EVM_LABELS` в `src/rpc_pool/sources/blockpi.py` вместе с `source_refs`.

**Чего в таблице НЕТ и почему:**

- **PublicNode `peaq`**: это EVM-сеть, её определяет проба EVM (`eip155:3338`), в таблице она не нужна.
- **Paradex Sepolia** (Hyperlane `paradexsepolia`, Starknet): единственный адрес на пробу отвечает `503`, identity не прочитать. Адрес ждёт в `pending`.
- **Bitcoin SV** (PublicNode `bsv`): делит нулевой блок с Bitcoin, identity его от Bitcoin не отличает. Адрес остаётся в `pending` с неизвестной экосистемой и в пул не попадает.
- **PublicNode `tron-evm`**: это EVM JSON-RPC Tron в корне пути, проверяльщик Tron его так не узнает (он ждёт `/jsonrpc`). Без привязки его определяет проба EVM, и адрес попадает в `eip155:728126428`.
- **Aleo, Radix** (Hyperlane `aleo`, `radix`, `radixtestnet`): для этих экосистем нет проверяльщика, адреса ждут в `pending`.

Если у сети все адреса дают `wrong_chain` (счётчик в `/v1/stats`, `check`), а сами узлы рабочие, вероятнее всего неверна запись в таблице.

## 6. Как добавить не-EVM сеть, шаг за шагом

Предусловие: у экосистемы сети есть проверяльщик (13 штук, раздел 4). Если нет, сначала раздел 7.

1. **Узнай identity.** Если адреса сети уже приходят из каталогов, дождись планового check (или запусти `uv run rpc-pool check`): пул сам заведёт автосеть (раздел 9), и её identity лежит в базе
   (`SELECT key, name, identities FROM chains WHERE key LIKE '%:x-%'`). Identity берётся оттуда как есть. Если автосети нет, посмотри `GET /v1/unmapped`:
   группа показывает `ecosystem`, `identity`, `names`, `sources` и `sample_urls`.
   Адреса `sample_urls` **не дёргай руками** с основного IP: они уже проверены самим сервером пула (это по замыслу), а любой рабочий запрос к RPC идёт только через прокси (`docs/RULES.md`).
   Нужна сеть, чьих адресов в unmapped нет (они висят в `pending`, экосистема источника неизвестна)? Тогда identity возьми из официальной документации сети или спроси узел через свой прокси: `curl --noproxy '' -x "$PROXY" ...`.
2. **Узнай ref источников** (нужны для `source_refs`, если каталоги называют сеть своим именем). Их нет в API, смотри базу (чтение не мешает работающему серверу):
   ```bash
   sqlite3 data/rpc_pool.db "SELECT source, ref, ecosystem, COUNT(*) FROM unmapped GROUP BY 1, 2, 3"
   sqlite3 data/rpc_pool.db "SELECT source, ref, ecosystem, COUNT(*) FROM pending GROUP BY 1, 2, 3"
   ```
   Пара `source` и `ref` (ref сравнивается без учёта регистра) идёт в `source_refs`.
3. **Допиши запись** в `src/rpc_pool/data/chains_nonevm.yaml` по образцу раздела 5: identity в кавычках, ровно как она пришла, `is_testnet` по сути сети, алиасы нижним регистром.
   Ключ вида `<экосистема>:x-...` (своя экосистема записи, например `solana:x-fork`) загрузчик таблицы отвергает с ошибкой: это пространство ключей автосетей (раздел 9).
   `source_refs` нужны, если источник даёт сеть без chain id (PublicNode, BlockPI...) или под своим названием: только они привяжут такой адрес к сети и скажут ей экосистему.
4. **Обнови тесты.** `EXPECTED_IDENTITIES` в `tests/test_registry.py` дублирует identity таблицы, это намеренный страховочный тест: без правки он упадёт на новой сети.
   Допиши туда `"<key>": ("<identity>",)`. В том же файле проверяется, что testnet-флаг совпадает с суффиксом ключа (`:devnet`, `:testnet`, `:sepolia`, `:shasta`, `:signet`): для нового суффикса (`:holesky` и подобных) поправь и этот тест.
   Привязки `source_refs` при желании добавь в `test_packaged_table_source_refs`.
5. **Прогони тесты:** `uv run pytest tests/test_registry.py -x`, затем весь gate: `uv run ruff check --fix . && uv run ruff format . && uv run mypy --strict . && uv run pytest -q`.
6. **Перезапусти `serve`.** Таблица читается один раз при старте процесса, запущенный сервер правок не видит.
   Следующий collect создаст сеть и алиасы в базе и привяжет новые адреса (endpoints автосети с той же identity переедут в неё, раздел 9); следующий check определит `pending`. Сеть можно проверить сразу: `uv run rpc-pool once --chains <ключ сети>`.
   Ожидаемые identity проверка берёт из таблицы, а не из базы, поэтому их правка действует сразу после перезапуска. А вот `name`, `ecosystem` и `is_testnet` сети, которая уже есть в базе, не обновляются:
   строка `chains` создаётся один раз, и первое значение остаётся. Правка этих полей у уже существующей сети видна только после правки строки `chains` вручную или на новой базе.
7. **Проверь:** `GET /v1/chains/<ключ сети>` (сеть и её `alive`/`total`), затем `GET /v1/chains/<ключ сети>/rpcs`.
   Строки в `/v1/unmapped` по этой сети не исчезают мгновенно: URL выходит оттуда, когда следующий check определил его в сеть, либо ежедневная чистка удаляет строку, которую не видели 7 дней.

## 7. Как добавить проверяльщик (новая экосистема)

Нужен, когда сеть живёт в экосистеме, которой нет в списке из раздела 4.

1. **Значение `Ecosystem`** в `src/rpc_pool/models.py` (`StrEnum`, строка в нижнем регистре). После этого значение принимает фильтр `GET /v1/chains?ecosystem=` и поле `ecosystem` таблицы.
2. **Модуль** `src/rpc_pool/checkers/<имя>.py`: класс от `BaseChecker` с атрибутами `ecosystem`, `transports` (`frozenset` из `Transport.HTTP` и/или `Transport.WS`) и методом
   `async _identity_and_height(ctx, url, transport, watch) -> tuple[str, int]` (identity и высота). Образцы: `solana.py` (два JSON-RPC вызова), `near.py` и `cosmos.py` (один запрос даёт обе величины), `aptos.py` (REST), `tron.py` (два режима).
   - Запросы только через помощников из `checkers/base.py`: `jsonrpc`, `http_get_json`, `http_post_json`, разбор ответа `dig`, `hex_int`, `decimal_int`, `identity_text`. Они держат лимиты (параллельность, лимит на хост, таймаут) и превращают любую ошибку в `RpcCallError`.
     Собственного `ClientSession` и голого `aiohttp` быть не должно.
   - `watch` передавай **только** в запрос identity: именно его длительность станет `latency_ms`.
   - Не бросай своих исключений на нездоровом узле: ответ не по форме это `RpcCallError("bad_json", ...)` (даёт `fail`), `429` это `rate_limited` (даёт `inconclusive`).
   - Identity возвращай в одном каноническом виде (нижний регистр, без ведущих нулей и т. п.), чтобы в таблице была одна запись.
3. **Регистрация:** импорт и экземпляр в кортеж `_CHECKERS` в `src/rpc_pool/checkers/__init__.py`. Без этого экосистема останется без проверяльщика (`no_checker` в unmapped).
   В `SNIFF_ORDER` (экосистемы, которые пробуются на адресе с неизвестной экосистемой) новую обычно не добавляют: лишний запрос на каждый незнакомый адрес.
   Если всё же добавишь проверяльщик с `Transport.WS`, collect начнёт сохранять и `wss`-адреса неизвестных экосистем (`ws_supported` в `src/rpc_pool/pipeline/scheduler.py` спрашивает проверяльщиков из `SNIFF_ORDER`).
4. **Подсказки источников.** Чтобы источники называли новую экосистему, добавь её в их словари соответствия: `_ECOSYSTEMS` в `layerzero.py`, `lifi.py`, `hyperlane.py`, `rpcnode.py` (если их данные это содержат).
5. **Тесты:** `tests/test_checkers_*.py` по образцу соседних: фейковый узел из `tests/fakes.py` (`FakeRpc`), проверки на нормальный ответ, мусор, HTTP-ошибки, `429`, неправильный тип поля, WS (если заявлен). Проверь `get_checker(<экосистема>)`.
6. **Сети** новой экосистемы заводятся в таблице (раздел 6). WS-адреса этой экосистемы collect будет сохранять, только если её проверяльщик заявил `Transport.WS`.
   Исключение: адрес с неизвестной экосистемой collect сохраняет, если `Transport.WS` заявил хоть один проверяльщик из `SNIFF_ORDER`.
7. **Документация:** строка в таблицу раздела 4.

## 8. Как читать `/v1/unmapped`

`GET /v1/unmapped` (`docs/API.md`) показывает URL, чью сеть пул определить не смог. В выдачу они не попадают, но видны здесь. Каждая группа это (экосистема, identity, причина).
Сети с неизвестной identity в экосистеме с проверяльщиком пул заводит сам (раздел 9), поэтому в unmapped остаётся немногое:

| `reason` | Что значит | Что делать |
|---|---|---|
| `no_table_entry` | узел ответил, кто он, но из его identity нельзя собрать ключ автосети: в ней нет ни одного символа `[a-z0-9]` | редкость. `identity` в ответе; если это настоящая сеть, добавь её в таблицу (раздел 6) |
| `no_checker` | для экосистемы нет проверяльщика | добавь проверяльщик (раздел 7). Сейчас не встречается: у всех 13 экосистем он есть |

Строки `no_table_entry`, записанные до автосетей, определяются, когда их URL снова пройдёт через `pending` (его возвращает следующий collect), и тогда пропадают отсюда.

Признаки в группе:

- Несколько разных `names` в одной группе: каталоги называют эту сеть по-разному. Запись в `source_refs` на каждое из названий (раздел 6, шаг 2) привяжет их все.
- Группы упорядочены по размеру, самая большая сверху. Адреса в `sample_urls` показаны с замаскированными ключами (`***`).
- Строка в unmapped живёт, пока URL появляется в каталогах и не определился; ежедневная чистка удаляет строки, которых не видели 7 дней.
- Если нужной сети в unmapped нет, а в каталогах она есть, проверь `pending` (раздел 6, шаг 2): возможно, экосистему источника не определить без `source_refs`.

## 9. Автосети: `<экосистема>:x-<slug>`

Новая сеть из каталогов не ждёт, пока кто-то допишет таблицу. Если у экосистемы есть проверяльщик (сейчас у всех 13) и узел из `pending` ответил identity, которой нет в таблице, пул сам заводит сеть и отдаёт её RPC как любые другие.
Так появляются форки, новые L1 и тестовые сети без записи в `chains_nonevm.yaml`. EVM и Cosmos автосетей не знают: их ключ это id из ответа узла (раздел 1).

**Ключ.** `<экосистема>:x-<slug>`, например `sui:x-0f1e2d3c4b5a6978`. `slug` это identity в нижнем регистре, без ведущего `0x`, только `[a-z0-9]`, не больше первых 16 символов (короткая identity даёт короткий slug).
Ключ зависит только от identity (и от уже занятых ключей), поэтому он стабилен: следующие collect и check приходят к той же сети, дубликата нет, а два адреса с одной новой identity в одном check получают одну сеть.

- Ключ уже занят другой identity: берутся первые 24 символа slug, потом 32, в последнюю очередь первые 16 символов SHA-256 от identity.
- В identity нет ни одного символа `[a-z0-9]` (скажем, `---`): ключа не из чего собрать. Такая строка остаётся в `/v1/unmapped` с `reason: no_table_entry`.
- Экосистема без проверяльщика (`no_checker`) по-прежнему только в `/v1/unmapped`.

**Что записано в сеть.** Экосистема та, чей проверяльщик получил ответ. `name` берётся из подсказок каталогов: первое непустое название среди адресов с этой identity в том check, который завёл сеть (адреса идут по (URL, источник)); если названия нет ни у одного, это сам ключ. Позже название не обновляется. `is_testnet` равен `null`: по ответу узла mainnet от testnet не отличить.
`identities` одна, та, что ответил узел. Алиасов нет, сеть называют ключом (`GET /v1/chains/sui:x-0f1e2d3c4b5a6978/rpcs`). В `GET /v1/chains` она видна как любая другая; `/ui` помечает такие ключи значком «авто», а блок «Новые сети за 7 дней» на обзоре (`new_chains` в `GET /v1/overview`) показывает сети, созданные за неделю.

**Как это хранится.** Автосеть лежит в таблице `chains` как обычная. Реестр читает такие сети из базы в начале каждого check, поэтому после перезапуска `serve` они узнаются, а ожидаемая identity берётся из той же строки. Узнать identity автосети:

```bash
sqlite3 data/rpc_pool.db "SELECT key, name, identities, created_at FROM chains WHERE key LIKE '%:x-%'"
```

**Дать автосети красивый ключ.** Допиши запись с той же identity в `chains_nonevm.yaml` (раздел 6) и перезапусти `serve`. Таблица всегда главнее автосети. Первый же collect после перезапуска в одной транзакции:

1. переносит endpoints автосети в сеть из таблицы вместе со статусами, источниками и историей проверок;
2. если такой URL в сети из таблицы уже есть, остаётся он, а источники дубликата добавляются к нему;
3. удаляет опустевшую автосеть и её собственные алиасы;
4. оставляет старый ключ `…:x-…` алиасом нового (источник алиаса `auto`).

Старый ключ после этого продолжает работать: `GET /v1/chains/<старый ключ>/rpcs` отвечает RPC сети из таблицы, а `chain.key` в ответе уже новый. Для новых скриптов бери новый ключ (он виден в `GET /v1/chains` и в `chain.key`), а алиас оставь на старые.
Если каталог заявил тот же текст как алиас для другой сети, он по обычному правилу алиасов (раздел 2, «Конфликт») становится неоднозначным и не работает ни для одной. Автосеть, у которой identity лишь частично совпадает с таблицей, не трогают.
Ключ, оставшийся алиасом, занят: другая identity с тем же началом slug получит более длинный ключ (24 или 32 символа), и старый ключ не начнёт вести на чужую сеть. Границы стабильности ключа: она держится, пока автосеть жива или промоутнута. Пустую автосеть чистка удаляет вместе с ключом, и потом тот же ключ может достаться другой identity с тем же началом slug (та же identity, вернувшись, получит свой прежний ключ, если он не занят).

**Collect и автосети.** URL, который уже лежит в endpoints автосети, collect при следующем сборе обновляет прямо там (источник, `last_seen_in_source`), если экосистема подсказки совпала с экосистемой автосети. В `pending` он не попадает и второй раз не зондируется. Identity проверит обычный check; ответ другой сети уйдёт в `wrong_chain` как у любого endpoint. Подсказка с ключом из таблицы (`source_refs`) главнее.

Что будет, если узел автосети начнёт отвечать другой identity:

- **Известная** identity (запись таблицы или другая автосеть): endpoint получает `wrong_chain`, и в нужной сети сразу заводится кандидат, его проверит следующий check (раздел 3, «`wrong_chain` и переезд»).
- **Новая, никому не известная** identity: endpoint остаётся в старой автосети со статусом `wrong_chain` (не отдаётся) и в новую сеть сам не переезжает, потому что collect больше не отправляет его в `pending`. Его удалит чистка, когда `last_ok_at` станет старше `RPC_POOL_DEAD_AFTER_H` часов (до суток плюс период чистки). После этого каталог принесёт URL заново, он пройдёт через `pending`, и узнанная identity заведёт для него автосеть.

**Чистка.** Ежедневная чистка удаляет автосеть, если у неё не осталось ни одного endpoint и её строка старше `RPC_POOL_DEAD_AFTER_H` часов (по умолчанию 24): узел, который отвечает каждый раз другой identity, иначе оставил бы по сети на каждую. Алиасы уходят вместе с сетью. Сети из таблицы, EVM и Cosmos чистка не трогает; число удалённых сетей в `cleanup.stats.chains_deleted`.
Если узел вернётся позже с той же identity, сеть заведётся заново под тем же ключом, пока ключ никто не занял (см. выше про границы стабильности).

**Чего автосеть не делает.** URL, который каталог относит к известной сети, но узел отвечает identity, которой не знает никто, остаётся `wrong_chain` и сам в автосеть не переезжает: автосеть заводится только из `pending`, то есть для адресов, чью сеть каталог не назвал. Переезд при `wrong_chain` работает и для автосетей, если identity уже известна.
