From 6e95bb3e064e7cfcac45b5709a56db477b036dc6 Mon Sep 17 00:00:00 2001 From: privatekey7 <36263200+privatekey7@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:32:00 +0300 Subject: [PATCH] v2.0.0: verified balances and faster checks Since 11.09.2026 Rabby API returns other wallets' data for some addresses (total_balance, complex_app_list, token_list), which produced phantom totals. - Build the total only from verified components: tokens confirmed on-chain via public RPCs, EVM DeFi recomputed from assets, app-chain positions from native Hyperliquid / Lighter / Polymarket APIs; Rabby aggregate is a check only - Accept a balance only when two independent samples agree, else UNVERIFIED; ignore samples with a substituted token list - Built-in verified RPC registry for all 73 Rabby chains - 2-3x faster: parallel requests, per-proxy rate limits and rotation, proxy precheck, background NFT loading, disk cache - audit_passes.py for multi-pass stability reports - 54 offline tests; CI checks new modules --- .github/workflows/ci.yml | 4 +- .gitignore | 5 +- CHANGELOG.md | 64 ++ README.md | 189 +++- audit_passes.py | 211 ++++ debank_checker/__init__.py | 3 +- debank_checker/api/__init__.py | 3 +- debank_checker/api/http.py | 132 +++ debank_checker/api/hyperliquid_client.py | 142 +++ debank_checker/api/lighter_client.py | 73 ++ debank_checker/api/onchain.py | 217 +++++ debank_checker/api/polymarket_client.py | 115 +++ debank_checker/api/rabby_client.py | 113 ++- debank_checker/checker.py | 1139 ++++++++++++++++------ debank_checker/config.py | 114 ++- debank_checker/data/rpc_registry.json | 441 +++++++++ debank_checker/diskcache.py | 71 ++ debank_checker/export/__init__.py | 1 + debank_checker/export/common.py | 1 + debank_checker/export/config.py | 1 + debank_checker/export/csv_exporter.py | 1 + debank_checker/export/excel.py | 1 + debank_checker/export/json_exporter.py | 1 + debank_checker/parallel.py | 119 +++ debank_checker/proxy/__init__.py | 1 + debank_checker/proxy/manager.py | 47 +- debank_checker/ui/__init__.py | 1 + debank_checker/ui/banner.py | 5 +- debank_checker/ui/logger.py | 1 + debank_checker/ui/menu.py | 17 +- docs/PHANTOM_BALANCES.md | 137 +++ main.py | 143 ++- tests/test_menu.py | 18 + tests/test_parallel.py | 72 ++ tests/test_rabby_client.py | 1 + tests/test_rabby_snapshot.py | 513 +++++++++- tools/build_rpc_registry.py | 171 ++++ 37 files changed, 3819 insertions(+), 469 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 audit_passes.py create mode 100644 debank_checker/api/http.py create mode 100644 debank_checker/api/hyperliquid_client.py create mode 100644 debank_checker/api/lighter_client.py create mode 100644 debank_checker/api/onchain.py create mode 100644 debank_checker/api/polymarket_client.py create mode 100644 debank_checker/data/rpc_registry.json create mode 100644 debank_checker/diskcache.py create mode 100644 debank_checker/parallel.py create mode 100644 docs/PHANTOM_BALANCES.md create mode 100644 tests/test_menu.py create mode 100644 tests/test_parallel.py create mode 100644 tools/build_rpc_registry.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cb2d9c..a0282d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - name: Import smoke check run: | - python -c "import debank_checker.api.rabby_client, debank_checker.checker, debank_checker.export.csv_exporter, debank_checker.export.excel, debank_checker.export.json_exporter, debank_checker.proxy.manager, debank_checker.ui.menu" + python -c "import debank_checker.api.rabby_client, debank_checker.api.hyperliquid_client, debank_checker.api.lighter_client, debank_checker.api.polymarket_client, debank_checker.api.onchain, debank_checker.api.http, debank_checker.parallel, debank_checker.diskcache, debank_checker.checker, debank_checker.export.csv_exporter, debank_checker.export.excel, debank_checker.export.json_exporter, debank_checker.proxy.manager, debank_checker.ui.menu" - name: Run tests run: | @@ -30,5 +30,5 @@ jobs: - name: Bytecode compile run: | - python -m compileall -q debank_checker main.py + python -m compileall -q debank_checker main.py audit_passes.py diff --git a/.gitignore b/.gitignore index 2cb375e..9ddf3a4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,13 +10,16 @@ venv/ env/ # Output +.cache/ Results/ -debug_log.txt logs.txt +*.log +.pytest_cache/ # Local inputs (do not commit) wallets.txt proxy.txt +rpc.txt # IDE .cursor/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1a6d810 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,64 @@ +# Changelog + +## 2.0.0 — 23.09.2026 + +Проверка в 2–3 раза быстрее при тех же правилах защиты от фантомных балансов. + +### Защита от фантомных балансов (с 11.09.2026 Rabby отдаёт чужие данные) + +Подробности — `docs/PHANTOM_BALANCES.md`. + +- Итог собирается только из проверенных компонентов: токены из + `cache_token_list` с обязательным on-chain подтверждением (≥ $0.5), EVM + DeFi-позиции с пересчётом по `asset_token_list`, app-chain позиции только из + нативных API Hyperliquid / Lighter / Polymarket. Агрегат Rabby — только + контроль. +- Баланс принимается, когда сошлись 2 независимые выборки, иначе `UNVERIFIED`. +- Защита от подмены списка токенов: Rabby иногда отдаёт для кошелька чужой + список целиком, где наших токенов нет. On-chain проверка снимала чужие токены, + но пропажу наших не видела, и две такие выборки могли «сойтись» на пыли + (кошелёк с ~$253 получил OK $1.44). Теперь в согласии не участвуют + «заражённые» выборки (on-chain сняла > $5 и > 10% стоимости) и «неполные» + (нет токена, подтверждённого на цепочке в другой выборке). Новое поле + результата `onchain_rejected_usd`. +- Встроенный реестр проверенных RPC для всех 73 сетей Rabby. +- `audit_passes.py` — N независимых проходов с отчётом стабильности. + +### Скорость (62 с → 21–32 с на 245 кошельков через 100 прокси) + +- Все независимые запросы выборки идут параллельно; on-chain проверка + токенов совмещена со спекулятивным перезапросом `token_list`. +- Две корроборирующие выборки кошелька идут одновременно через разные прокси; + зависшая выборка дублируется хеджем (`SNAPSHOT_HEDGE_SEC`) с отдельным кэшем. +- Нативные API и on-chain проверки — один раз на кошелёк (singleflight), + без процессных кэшей между проходами: проходы аудита независимы. +- Пул keep-alive HTTP-сессий на прокси; хедж медленных RPC-нод; cooldown + RPC-ноды ведётся на пару (нода, прокси). +- Через прокси 429/5xx не ждут на том же IP — повтор сразу через другой + прокси; прокси с 429/403 уходит в короткий cooldown. +- Предпроверка прокси при старте: мёртвые (407) исключаются; прокси, умерший + во время работы, исключается до конца запуска. +- NFT грузятся в фоне и не задерживают баланс; экспорт ждёт их только если + в нём есть NFT. Колонка `chains` — только сети с балансом (токены + DeFi). Таймаут `collection_list` + поднят до 25 с: для кошельков с сотнями коллекций сервер считает ответ + 6–20 с, и прежний таймаут 8 с всегда оставлял их NFT пустыми. +- Дисковый кэш (`.cache/`) списка сетей Rabby, реестра chainid.network и + Polymarket proxy-адресов. + +### Структура + +- Новые модули: `debank_checker/parallel.py`, `debank_checker/diskcache.py`, + `debank_checker/api/http.py` (заменил `api/ratelimit.py`). +- `main.py`: общие `check_all()` / `worker_count()` используются и + `audit_passes.py`. +- Удалены отладочные скрипты и логи (`probe_live.py`, `audit_*.log`); + отчёт о фантомах перенесён в `docs/`. +- Удалены неиспользуемые параметры (`NFT_POLL_*`, `HL_CACHE_SEC`) и поле + `onchain_unverified_usd` (всегда было 0). +- Тесты: 54 офлайн-теста, включая хедж, фоновые NFT, бан мёртвого прокси и + примитивы параллельности. + +## 1.0.3 + +- Чистый Rabby API: исправлены заголовки авторизации и ротация ключа, ветка + DeBank удалена. diff --git a/README.md b/README.md index 7e80ff5..b89f4f9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/privatekey7/DeBankChecker/actions/workflows/ci.yml/badge.svg)](https://github.com/privatekey7/DeBankChecker/actions/workflows/ci.yml) -CLI‑утилита на Python для массовой проверки кошельков через DeBank API (**Tokens + DeFi + NFT**) с экспортом результатов (**Excel / CSV / JSON**). +CLI‑утилита на Python для массовой проверки кошельков через Rabby API с независимой on-chain проверкой (**Tokens + DeFi + NFT**) с экспортом результатов (**Excel / CSV / JSON**). **TG:** `https://t.me/privatekey_ai` @@ -11,8 +11,11 @@ CLI‑утилита на Python для массовой проверки кош ## Возможности - **Массовая проверка** списка адресов из `wallets.txt` -- **Прокси‑ротация** + rate limiting / cooldown (для стабильности) -- **Параллельная обработка** кошельков +- **Защита от фантомных балансов**: итог собирается только из проверенных + компонентов (on-chain, нативные API), каждый кошелёк подтверждается двумя + независимыми выборками +- **Скорость**: 20–30 с на 245 кошельков через 100 прокси (см. «Скорость») +- **Прокси‑ротация** + rate limiting / cooldown, мёртвые прокси отсеиваются при старте - **Экспорт**: - Excel (`.xlsx`) - CSV @@ -23,7 +26,7 @@ CLI‑утилита на Python для массовой проверки кош ## Требования -- **Python 3.10+** (рекомендуется) +- **Python 3.9+** (рекомендуется 3.11) - Windows / macOS / Linux Зависимости перечислены в `requirements.txt` (ключевые: `curl_cffi`, `openpyxl`, `questionary`, `colorama`). @@ -94,12 +97,17 @@ python main.py Поток работы: -1. Запускается проверка всех кошельков -2. Печатается прогресс‑бар -3. После завершения открывается **интерактивное меню** экспорта (что именно выгружать) +1. Прокси проходят быструю предпроверку (мёртвые — 407 — исключаются) +2. Запускается проверка всех кошельков, печатается прогресс‑бар +3. После завершения печатается итог и открывается **интерактивное меню** экспорта 4. Вы выбираете формат (**Excel / CSV / JSON**) 5. Файлы сохраняются в папку `Results/` +NFT в сумму не входят и догружаются в фоне, пока вы работаете с меню. +Ждать их программа будет только для экспорта с NFT (пункты 5, 6, 7 и 11) — +обычно к этому моменту они уже готовы. Остальные экспорты идут сразу. Колонка +`chains` содержит сети с балансом (токены и DeFi); сети NFT — на листе NFT. + --- ## Результаты @@ -112,35 +120,148 @@ python main.py --- -## Источник балансов: Rabby API +## Источник балансов: Rabby API + независимая проверка + +Данные получаются через **Rabby API** (`api.rabby.io`), но **итог никогда не +берётся из агрегата** `total_usd_value`. С 11.09.2026 бэкенд Rabby для части +адресов возвращает **чужие данные** (расследование — `docs/PHANTOM_BALANCES.md`): + +- `/v1/user/total_balance` — агрегат с суммами другого кошелька (одни и те же + значения у разных адресов, «липнут» минуты-часы); +- `/v1/user/complex_app_list` — случайные app-chain позиции (Hyperliquid, + Lighter, Polymarket…) с нашим `user_addr`, но чужими суммами; +- `/v1/user/token_list` и иногда `/v1/user/cache_token_list` — отдельные + чужие токены. + +Поэтому баланс собирается из компонентов, каждый из которых проверяется: -Балансы получаются через **Rabby API** (`api.rabby.io`). Итог кошелька берётся -готовым агрегатом одним запросом (`/v1/user/total_balance?is_core=true`, -`total_usd_value` уже включает токены и DeFi), поэтому сумма не считается -вручную и не зависит от polluted-ответов. `is_core=true` оставляет только -проверенные (core) токены. Версия веб-клиента Rabby — в `RABBY_CLIENT_VERSION` -(`debank_checker/config.py`); при поломке подписи обнови её из свежего HAR. +| Компонент | Источник | Проверка | +|---|---|---| +| Токены кошелька | `cache_token_list` (core, не скам) | **on-chain**: `eth_getBalance` / `balanceOf` через публичные RPC (`api/onchain.py`); фантом (0 на цепочке) отбрасывается, количество исправляется по сети. Токен ≥ $0.5 **без** on-chain подтверждения → выборка не засчитывается | +| EVM DeFi-позиции | `complex_app_list` (позиции с `chain`) | пересчёт по `asset_token_list`, `min(api, recalc)` | +| App-chain позиции | **нативные API** Hyperliquid / Lighter / Polymarket (proxy-кошелёк через Safe-фабрику на Polygon + data-api) | значения Rabby для этих приложений игнорируются; прочие app-chain (Opinion и т.п.) в итог не входят и помечаются | +| Агрегат Rabby | `total_balance` | **только контроль**: расхождение с проверенными компонентами пишется в «Примечание» | + +Баланс принимается (статус `OK`), когда **две** независимые выборки сошлись +(`CORROBORATION_MIN_AGREE=2`). В согласии не участвуют выборки, где Rabby +подменил список токенов: если on-chain проверка сняла заметную часть токенов +(`TAINT_ABS_USD`/`TAINT_REL`) или если в выборке нет токена, подтверждённого +на цепочке в другой выборке. Иначе — `UNVERIFIED` с консервативным +значением; такие кошельки в сумму не входят. ### Заголовки должны точно повторять веб-клиент Анти-бот Rabby на любое отклонение в заголовках идентификации отвечает -**фейковым 429 с пустым телом** (при полностью корректной подписи; проверено -на live-API). Требования (сверены с HAR веб-клиента, `tests/fixtures/`): - -- все заголовки — в **нижнем регистре**: `x-api-key`, `x-api-time`, ...; -- `x-api-time` — **время выдачи текущего API-ключа**, а не время запроса - (для init-ключа — `RABBY_API_KEY_INIT_TIME` в конфиге); -- `x-api-ver` **не отправляется**. - -Ротированный сервером ключ (`x-set-api-key`) читается из любого ответа, -включая ошибочные, и сохраняется в общем магазине ключей на процесс — новый -клиент на каждую попытку продолжает с последнего выданного ключа, а не -стартует заново с init-ключа. - -## Защита от «фантомных» балансов (страховка) - -Сохранён механизм **корроборации**: баланс принимается, если подтверждён -`CORROBORATION_MIN_AGREE` независимыми выборками, сошедшимися по сумме. -Для Rabby итог авторитетен, поэтому по умолчанию `MIN_AGREE=1` (лишних -запросов нет), а механика остаётся как страховка. Параметры — в -`debank_checker/config.py` (`CORROBORATION_*`). +**фейковым 429 с пустым телом**. Требования (сверены с HAR веб-клиента, +`tests/fixtures/`): заголовки в нижнем регистре; `x-api-time` — время выдачи +текущего API-ключа (`RABBY_API_KEY_INIT_TIME`); ротированный ключ +(`x-set-api-key`) сохраняется в общем магазине на процесс. + +## Многопроходная сверка (`audit_passes.py`) + +```bash +python audit_passes.py --passes 5 # через прокси из proxy.txt +python audit_passes.py --passes 5 --direct # без прокси +``` + +Выполняет N полных проходов по всем кошелькам; кошелёк считается +**STABLE**, если ≥ N результатов `OK` сошлись в пределах допуска. Для +остальных делаются дополнительные точечные проходы (`--max-extra`). +Результаты — в `Results/audit_<дата>/`: `audit_report.xlsx/csv` (значения по +проходам, разброс, вердикт, отклонённые агрегаты Rabby), `pass_k.jsonl`, +`final_results.json` и стандартный Excel-экспорт. Для каждого прохода +печатается время баланса и время с догрузкой NFT. Код возврата 0 — все +кошельки стабильны. + +## Скорость + +Проверка оптимизирована по времени **без ослабления проверок** (все правила +защиты от фантомов, on-chain подтверждение и корроборация двумя выборками +сохранены): + +- внутри выборки все независимые запросы идут **параллельно** (Rabby ×3, + Hyperliquid/Lighter/Polymarket, on-chain проверка токенов, перезапрос + `token_list` по подозрительным сетям); +- две корроборирующие выборки кошелька идут **одновременно** через разные + прокси; выборка, не ответившая за `SNAPSHOT_HEDGE_SEC`, дублируется новой; +- нативные API и on-chain проверки выполняются **один раз на кошелёк**, а не + в каждой выборке; +- keep-alive пул HTTP-сессий на каждый прокси, хедж медленных RPC-нод, + дисковый кэш списка сетей Rabby и Polymarket proxy-адресов; +- через прокси 429/5xx не «пересыпаются» на том же IP — повтор сразу идёт + через другой прокси; мёртвые прокси исключаются; +- NFT (для кошельков с сотнями коллекций сервер считает их до 20 с) грузятся + в фоне и не задерживают баланс. + +Замер (245 кошельков, 95 живых прокси): v1.x — 62 с на проход, v2.0 — 21–32 с +(5 проходов подряд, 245/245 STABLE). + +## Настройки + +Настраивать ничего не нужно: положите адреса в `wallets.txt`, прокси в +`proxy.txt` и запустите `python main.py`. Число воркеров выбирается +автоматически — `min(500, кошельков, прокси × 5)`; все лимиты внешних сервисов +(Rabby, Hyperliquid, Polymarket, Lighter, публичные RPC) ведутся **на каждый +прокси отдельно**, поэтому скорость растёт с числом прокси. Все параметры с +комментариями — в `debank_checker/config.py`. + +### `rpc.txt` (необязательно) + +Встроенный реестр `debank_checker/data/rpc_registry.json` содержит живьём +проверенные публичные RPC для всех 73 сетей Rabby (от 1 до 17 нод на сеть); +при проверке перебираются **все** ноды сети в 3 раунда (медленная нода +дублируется следующей через `ONCHAIN_RPC_HEDGE_SEC`). Если хотите добавить +свои (Alchemy, Infura, dRPC…), создайте `rpc.txt` в корне — они пойдут первыми: + +``` +# chain_id url +1 https://eth-mainnet.g.alchemy.com/v2/КЛЮЧ +42161 https://arb-mainnet.g.alchemy.com/v2/КЛЮЧ +``` + +Обновить встроенный реестр: `python tools/build_rpc_registry.py`. + +## Прямой режим (без прокси) + +По умолчанию прокси обязательны. Для запуска без прокси: `DBC_DIRECT=1 +python main.py` или `python audit_passes.py --direct`. С одного IP всё +медленнее: 8 воркеров, 4 запроса/с к Rabby, повторы после 429 с паузой. + +## Структура проекта + +``` +main.py — интерактивный запуск: проверка → меню → экспорт +audit_passes.py — N независимых проходов и отчёт стабильности +debank_checker/ + checker.py — выборка баланса, защита от фантомов, корроборация + config.py — все настройки (с комментариями) + parallel.py — параллельный запуск, singleflight-кэш, хедж + diskcache.py — кэш в .cache/ между запусками + api/ + http.py — пул keep-alive сессий на прокси, лимитеры + rabby_client.py — Rabby API (подпись запросов, ротация ключа) + onchain.py — on-chain проверка через публичные RPC + hyperliquid_client.py — нативный API Hyperliquid + lighter_client.py — нативный API Lighter + polymarket_client.py — Polymarket (proxy-кошелёк + data-api) + data/rpc_registry.json — проверенные RPC для всех сетей Rabby + proxy/manager.py — загрузка, предпроверка и ротация прокси + export/ — Excel / CSV / JSON + ui/ — баннер, логгер, меню +tools/build_rpc_registry.py — пересборка реестра RPC +tests/ — офлайн-тесты (pytest) +docs/PHANTOM_BALANCES.md — расследование фантомных балансов (09.2026) +CHANGELOG.md — история версий +``` + +## Тесты + +```bash +python -m pytest tests -q +``` + +## Логи + +`logs.txt` — по каждой выборке: итог, токены, протоколы, агрегат Rabby, +примечания; при отклонённом агрегате или фантомных app-chain позициях — +сырые данные (ограничено `RAW_LOG_MAX_BYTES`). diff --git a/audit_passes.py b/audit_passes.py new file mode 100644 index 0000000..c63830c --- /dev/null +++ b/audit_passes.py @@ -0,0 +1,211 @@ +""" +Многопроходная сверка балансов: N полных независимых проходов по всем +кошелькам и итоговый отчёт стабильности. + + python audit_passes.py [--passes 5] [--workers 0] [--direct] [--max-extra 3] + +Каждый проход = check_wallet для каждого адреса (внутри — минимум +CORROBORATION_MIN_AGREE согласованных выборок). Кошелёк СТАБИЛЕН, если у него +≥ N результатов OK и все они сошлись в пределах допуска. Для остальных +выполняются дополнительные точечные проходы (до --max-extra). + +Результаты: Results/audit_/ + pass_.jsonl — результаты каждого прохода (без tokens/protocols/nft) + audit_report.xlsx — сводка по кошелькам: значения по проходам, разброс, вердикт + audit_report.csv — то же в CSV + final_results.json — итоговый результат по каждому кошельку (последний OK) + debank_*.xlsx — стандартный Excel-экспорт по финальным результатам + +Код возврата: 0 — все кошельки стабильны, 2 — есть нестабильные. +--direct: без прокси (по умолчанию прокси обязательны, как и в main.py). +""" +from __future__ import annotations + +import argparse +import csv +import json +import os +import sys +import time +from datetime import datetime +from pathlib import Path + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--passes", type=int, default=5) + ap.add_argument("--workers", type=int, default=0, help="0 = как main.py: min(500, кошельки, прокси×5); без прокси 8") + ap.add_argument("--direct", action="store_true", help="работать без прокси") + ap.add_argument("--max-extra", type=int, default=3, help="доп. проходов для нестабильных кошельков") + ap.add_argument("--wallets", default=None) + ap.add_argument("--limit", type=int, default=0, help="взять первые N кошельков (отладка)") + args = ap.parse_args() + + if args.direct: + os.environ["DBC_DIRECT"] = "1" + + # импорт после установки окружения: config читает DBC_DIRECT при загрузке + from debank_checker import __version__, config + from debank_checker.checker import _agree, wait_nfts + from debank_checker.export.excel import export_to_excel + from debank_checker.proxy.manager import ProxyManager, drop_dead_proxies, load_proxies + from main import check_all, load_wallets, setup_encoding, worker_count + + setup_encoding() + wallets = load_wallets(args.wallets or config.WALLETS_FILE) + if args.limit: + wallets = wallets[:args.limit] + proxies = [] if args.direct else load_proxies(config.PROXIES_FILE) + if not wallets: + print("Нет кошельков") + return 1 + if not proxies and not config.ALLOW_DIRECT: + print("Прокси обязательны (или --direct)") + return 1 + if proxies: + total_proxies = len(proxies) + proxies, _ = drop_dead_proxies(proxies) # мёртвые исключаются молча + if not proxies: + print("Ни один прокси не работает") + return 1 + workers = args.workers if args.workers > 0 else worker_count(len(wallets), len(proxies)) + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + out = Path(config.OUTPUT_DIR) / f"audit_{ts}" + out.mkdir(parents=True, exist_ok=True) + print(f"v{__version__} | кошельков: {len(wallets)} | {f'рабочие прокси: {len(proxies)} из {total_proxies}' if proxies else 'прокси: direct'} | " + f"проходов: {args.passes} | воркеров: {workers}") + print(f"Каталог: {out}") + + pm = ProxyManager(proxies) + results: dict[str, list[dict]] = {w: [] for w in wallets} + timings: list[tuple[int, int, float, float]] = [] # (проход, кошельков, баланс с, всего с) + + def slim(r: dict) -> dict: + return {k: v for k, v in r.items() if k not in ("tokens_data", "protocols_data", "nft_data")} + + def run_pass(k: int, targets: list[str]) -> None: + t0 = time.perf_counter() + + def progress(done: int, total: int) -> None: + if done % 50 == 0 or done == total: + print(f" проход {k}: {done}/{total} {time.perf_counter() - t0:.1f}s", flush=True) + + rows = check_all(targets, pm, workers, on_done=progress) + t_balance = time.perf_counter() - t0 + left = wait_nfts(timeout=180) + t_total = time.perf_counter() - t0 + with open(out / f"pass_{k}.jsonl", "a", encoding="utf-8") as f: + for addr, r in zip(targets, rows): + r["pass"] = k + results[addr].append(r) + f.write(json.dumps(slim(r), ensure_ascii=False) + "\n") + timings.append((k, len(targets), t_balance, t_total)) + ok = [r for r in rows if r["status"] == "OK"] + unv = sum(1 for r in rows if r["status"] == "UNVERIFIED") + err = sum(1 for r in rows if r["status"] == "ERROR") + print(f"Проход {k}: OK={len(ok)} UNVERIFIED={unv} ERROR={err} | сумма OK=${sum(r['total_usd'] for r in ok):,.2f}" + f" | баланс {t_balance:.1f}s, с NFT {t_total:.1f}s" + + (f" | NFT не догружены у {left}" if left else ""), flush=True) + + def stable(addr: str) -> tuple[bool, list[float]]: + oks = [r["total_usd"] for r in results[addr] if r["status"] == "OK"] + if len(oks) < args.passes: + return False, oks + base = min(oks) + return all(_agree(base, v) for v in oks), oks + + for k in range(1, args.passes + 1): + run_pass(k, wallets) + + extra = 0 + while extra < args.max_extra: + unstable = [w for w in wallets if not stable(w)[0]] + if not unstable: + break + extra += 1 + print(f"\nДоп. проход {extra}: нестабильных/недобранных кошельков {len(unstable)}") + run_pass(args.passes + extra, unstable) + + # --- отчёт --- + max_n = max(len(v) for v in results.values()) + rows = [] + final: list[dict] = [] + n_stable = 0 + for w in wallets: + ok_vals = [r["total_usd"] for r in results[w] if r["status"] == "OK"] + is_stable, _ = stable(w) + n_stable += is_stable + last_ok = next((r for r in reversed(results[w]) if r["status"] == "OK"), None) + chosen = last_ok or results[w][-1] + if not is_stable and chosen["status"] == "OK": + chosen = dict(chosen) + chosen["status"] = "UNVERIFIED" + chosen["error"] = (f"не набрано {args.passes} согласованных OK-проходов: " + + ", ".join(f"${v:.2f}" for v in ok_vals)) + ("; " + chosen["error"] if chosen.get("error") else "") + final.append(chosen) + row = {"address": w, "verdict": "STABLE" if is_stable else "UNSTABLE", + "ok_passes": len(ok_vals), "total_passes": len(results[w]), + "min": round(min(ok_vals), 2) if ok_vals else None, + "max": round(max(ok_vals), 2) if ok_vals else None, + "spread": round(max(ok_vals) - min(ok_vals), 2) if ok_vals else None, + "tokens_usd": round(chosen.get("tokens_usd", 0), 2), + "protocols_usd": round(chosen.get("protocols_usd", 0), 2), + "nft_collections": len(chosen.get("nft_data") or []), + "aggregate_rejected": sum(1 for r in results[w] if not r.get("aggregate_agrees", True)), + "rabby_aggregate_last": (round(chosen["aggregate_usd"], 2) if chosen.get("aggregate_usd") is not None else None), + "unverified_usd": round(chosen.get("unverified_usd", 0), 2), + "note": chosen.get("error", "") if chosen["status"] != "OK" else "; ".join(chosen.get("notes", []))} + for i in range(max_n): + r = results[w][i] if i < len(results[w]) else None + row[f"pass_{i + 1}"] = (round(r["total_usd"], 2) if r and r["status"] == "OK" + else (r["status"] if r else "")) + rows.append(row) + + fieldnames = list(rows[0].keys()) + with open(out / "audit_report.csv", "w", encoding="utf-8", newline="") as f: + wr = csv.DictWriter(f, fieldnames=fieldnames) + wr.writeheader() + wr.writerows(rows) + try: + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill + from openpyxl.utils import get_column_letter + wb = Workbook() + ws = wb.active + ws.title = "Audit" + ws.append(fieldnames) + for c in ws[1]: + c.font = Font(bold=True) + red = PatternFill("solid", fgColor="FCE8E6") + green = PatternFill("solid", fgColor="E6F4EA") + for row in rows: + ws.append([row[k] for k in fieldnames]) + for c in ws[ws.max_row]: + c.fill = green if row["verdict"] == "STABLE" else red + ws.column_dimensions["A"].width = 44 + ws.column_dimensions[get_column_letter(fieldnames.index("note") + 1)].width = 80 + wb.save(out / "audit_report.xlsx") + except Exception as e: # noqa: BLE001 — openpyxl не обязателен для отчёта + print(f"xlsx-отчёт не записан: {e}") + + with open(out / "final_results.json", "w", encoding="utf-8") as f: + json.dump([slim(r) for r in final], f, ensure_ascii=False, indent=1) + xlsx = export_to_excel(final, out) + + final_ok = [r for r in final if r["status"] == "OK"] + print("\n================ ИТОГ ================") + for k, n, t_bal, t_all in timings: + vals = [next((r["total_usd"] for r in results[w] if r.get("pass") == k and r["status"] == "OK"), None) + for w in wallets] + ok_n = sum(1 for v in vals if v is not None) + print(f" проход {k}: OK {ok_n}/{n} сумма ${sum(v for v in vals if v is not None):,.2f}" + f" | баланс {t_bal:.1f}s, с NFT {t_all:.1f}s") + print(f" стабильных кошельков ({args.passes}+ согласованных OK): {n_stable}/{len(wallets)}") + print(f" итоговая сумма (OK): ${sum(r['total_usd'] for r in final_ok):,.2f} ({len(final_ok)} кошельков)") + print(f" отчёт: {out / 'audit_report.xlsx'}\n Excel: {xlsx}") + return 0 if n_stable == len(wallets) else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/debank_checker/__init__.py b/debank_checker/__init__.py index e59b910..bd14a13 100644 --- a/debank_checker/__init__.py +++ b/debank_checker/__init__.py @@ -1,5 +1,6 @@ """ DeBank Checker — массовая проверка балансов кошельков через Rabby API. """ +from __future__ import annotations -__version__ = "1.0.3" +__version__ = "2.0.0" diff --git a/debank_checker/api/__init__.py b/debank_checker/api/__init__.py index 27437c3..c82a3ff 100644 --- a/debank_checker/api/__init__.py +++ b/debank_checker/api/__init__.py @@ -1,4 +1,5 @@ -"""API: клиент Rabby (api.rabby.io).""" +"""Внешние источники: Rabby API, публичные RPC, нативные API Hyperliquid/Lighter/Polymarket.""" +from __future__ import annotations from debank_checker.api.rabby_client import RabbyClient diff --git a/debank_checker/api/http.py b/debank_checker/api/http.py new file mode 100644 index 0000000..7b4bdef --- /dev/null +++ b/debank_checker/api/http.py @@ -0,0 +1,132 @@ +""" +Общий HTTP-слой: пул keep-alive сессий curl_cffi на каждый исходящий IP +(прокси или прямой) и token-bucket лимитеры «на IP». + +Сессия берётся из пула на один запрос и возвращается после него, поэтому +TCP/TLS-соединение (и CONNECT-туннель прокси) переиспользуется между +запросами разных потоков, а одновременно одну сессию не использует никто. +""" +from __future__ import annotations + +import threading +import time +from contextlib import contextmanager +from typing import Any, Iterator + +import curl_cffi.requests as cffi_requests + +IMPERSONATE = "chrome124" +_MAX_IDLE_PER_KEY = 32 + +_POOL_LOCK = threading.Lock() +_POOL: dict[str, list[cffi_requests.Session]] = {} + + +def _new_session(proxy: str | None) -> cffi_requests.Session: + kw: dict[str, Any] = {"impersonate": IMPERSONATE} + if proxy: + kw["proxies"] = {"https": proxy, "http": proxy} + return cffi_requests.Session(**kw) + + +@contextmanager +def session(proxy: str | None) -> Iterator[cffi_requests.Session]: + """Сессия из пула для данного прокси. После ошибки сессия закрывается.""" + key = proxy or "" + with _POOL_LOCK: + stack = _POOL.get(key) + s = stack.pop() if stack else None + if s is None: + s = _new_session(proxy) + ok = False + try: + yield s + ok = True + finally: + if ok: + with _POOL_LOCK: + stack = _POOL.setdefault(key, []) + if len(stack) < _MAX_IDLE_PER_KEY: + stack.append(s) + s = None + if s is not None: + try: + s.close() + except Exception: # noqa: BLE001 + pass + + +class ProxyDead(RuntimeError): + """Прокси отверг подключение (407 / CONNECT не удался) — повторять через него бессмысленно.""" + + def __init__(self, proxy: str | None, msg: str): + super().__init__(msg) + self.proxy = proxy + + +def _is_proxy_failure(err: Exception) -> bool: + s = str(err) + return "CONNECT tunnel failed" in s or "response 407" in s + + +def request(method: str, url: str, proxy: str | None, **kw: Any) -> cffi_requests.Response: + try: + with session(proxy) as s: + return s.request(method, url, **kw) + except Exception as e: + if proxy and _is_proxy_failure(e): + raise ProxyDead(proxy, f"прокси недоступен: {str(e)[:120]}") from e + raise + + +def get(url: str, proxy: str | None, **kw: Any) -> cffi_requests.Response: + return request("GET", url, proxy, **kw) + + +def post(url: str, proxy: str | None, **kw: Any) -> cffi_requests.Response: + return request("POST", url, proxy, **kw) + + +def retry_policy(proxy: str | None) -> tuple[int, float]: + """(попыток, базовая пауза) для нативных API. Через прокси — быстро + сдаёмся: выборку повторит check_wallet уже с другого IP.""" + return (2, 0.3) if proxy else (5, 1.0) + + +def retry_after(resp: cffi_requests.Response, default: float) -> float: + try: + return max(default, float(resp.headers.get("retry-after") or 0)) + except ValueError: + return default + + +class RateLimiter: + def __init__(self, per_sec: float): + self._interval = 1.0 / max(per_sec, 0.1) + self._lock = threading.Lock() + self._next = 0.0 + + def wait(self) -> None: + with self._lock: + now = time.monotonic() + delay = max(0.0, self._next - now) + self._next = max(now, self._next) + self._interval + if delay > 0: + time.sleep(delay) + + +class PerKeyLimiter: + """Отдельный RateLimiter на каждый ключ (исходящий IP: прокси или direct).""" + + def __init__(self, per_sec: float): + self._per_sec = per_sec + self._lock = threading.Lock() + self._limiters: dict[str, RateLimiter] = {} + + def wait(self, key: str | None) -> None: + k = key or "direct" + with self._lock: + lim = self._limiters.get(k) + if lim is None: + lim = self._limiters[k] = RateLimiter(self._per_sec) + lim.wait() diff --git a/debank_checker/api/hyperliquid_client.py b/debank_checker/api/hyperliquid_client.py new file mode 100644 index 0000000..24688c5 --- /dev/null +++ b/debank_checker/api/hyperliquid_client.py @@ -0,0 +1,142 @@ +""" +Нативный клиент Hyperliquid (api.hyperliquid.xyz/info) — независимая проверка +app-chain позиций, которые Rabby с 11.09.2026 отдаёт с чужими суммами. + +Публичный API без авторизации. Лимит ~1200 weight/мин на IP (info-запросы +weight 2), поэтому token-bucket ведётся на каждый исходящий IP (прокси). + +Считаем (три запроса идут параллельно): + * perp — clearinghouseState.marginSummary.accountValue (маржа + uPnL); + * spot — spotClearinghouseState.balances × midPx пары TOKEN/USDC; + * stake — delegatorSummary (delegated + undelegated + pending) × цена HYPE. +Любая сетевая/схемная ошибка → исключение (fail-closed): позиция не будет +принята «по умолчанию нулём», выборка повторится. +""" +from __future__ import annotations + +import time +from typing import Any + +from debank_checker.api import http +from debank_checker.api.http import PerKeyLimiter +from debank_checker.config import APPCHAIN_TIMEOUT, HL_API_URL, HL_RATE_PER_SEC +from debank_checker.parallel import Memo, run_parallel, unwrap + +_LIMITER = PerKeyLimiter(HL_RATE_PER_SEC) +_PRICES = Memo(ttl=60) + + +class HyperliquidError(RuntimeError): + pass + + +def _post(payload: dict, proxy: str | None) -> Any: + attempts, pause = http.retry_policy(proxy) + last: Exception | None = None + for attempt in range(attempts): + _LIMITER.wait(proxy) + try: + resp = http.post(HL_API_URL, proxy, json=payload, timeout=APPCHAIN_TIMEOUT) + if resp.status_code == 429: + time.sleep(pause if proxy else min(3.0 * (attempt + 1), 30.0)) + last = HyperliquidError("HTTP 429 from Hyperliquid") + continue + if resp.status_code != 200: + raise HyperliquidError(f"HTTP {resp.status_code} from Hyperliquid ({payload.get('type')})") + return resp.json() + except (HyperliquidError, http.ProxyDead): + raise + except Exception as e: # сеть/JSON + last = e + time.sleep(pause * (attempt + 1)) + raise HyperliquidError(f"Hyperliquid недоступен: {last}") + + +def get_spot_prices(proxy: str | None = None) -> dict[str, float]: + """Цены spot-токенов HL в USDC по midPx канонических пар TOKEN/USDC (кэш 60 с).""" + return dict(_PRICES.get("spot", lambda: _load_spot_prices(proxy))) + + +def _load_spot_prices(proxy: str | None) -> dict[str, float]: + data = _post({"type": "spotMetaAndAssetCtxs"}, proxy) + if not (isinstance(data, list) and len(data) == 2): + raise HyperliquidError("spotMetaAndAssetCtxs: неожиданная схема") + meta, ctxs = data + tokens = {t["index"]: t["name"] for t in meta.get("tokens", [])} + prices: dict[str, float] = {"USDC": 1.0} + for pair, ctx in zip(meta.get("universe", []), ctxs): + idx = pair.get("tokens") or [] + if len(idx) != 2 or idx[1] != 0: # котировка не в USDC + continue + base = tokens.get(idx[0]) + px = ctx.get("midPx") or ctx.get("markPx") + if base and px is not None: + try: + prices.setdefault(base, float(px)) + except (TypeError, ValueError): + pass + return prices + + +def _f(x: Any) -> float: + try: + return float(x or 0) + except (TypeError, ValueError): + raise HyperliquidError(f"нечисловое значение в ответе HL: {x!r}") + + +def get_positions(address: str, proxy: str | None = None) -> dict[str, Any]: + """Позиции адреса на Hyperliquid в USD. + + Возвращает {"total_usd", "perp_usd", "spot_usd", "stake_usd", + "details": [{"type","symbol","amount","value"}]}. + Бросает HyperliquidError при недоступности/неожиданной схеме. + """ + addr = address.lower() + r = run_parallel({ + "perp": lambda: _post({"type": "clearinghouseState", "user": addr}, proxy), + "spot": lambda: _post({"type": "spotClearinghouseState", "user": addr}, proxy), + "stake": lambda: _post({"type": "delegatorSummary", "user": addr}, proxy), + }) + perp, spot, stake = unwrap(r["perp"]), unwrap(r["spot"]), unwrap(r["stake"]) + if not isinstance(perp, dict) or "marginSummary" not in perp: + raise HyperliquidError("clearinghouseState: неожиданная схема") + if not isinstance(spot, dict) or "balances" not in spot: + raise HyperliquidError("spotClearinghouseState: неожиданная схема") + if not isinstance(stake, dict) or "delegated" not in stake: + raise HyperliquidError("delegatorSummary: неожиданная схема") + + details: list[dict] = [] + perp_usd = _f(perp["marginSummary"].get("accountValue")) + if perp_usd > 0: + details.append({"type": "Perpetuals", "symbol": "USDC", "amount": perp_usd, "value": perp_usd}) + + balances = [b for b in spot.get("balances", []) if _f(b.get("total")) > 0] + hype_amount = _f(stake.get("delegated")) + _f(stake.get("undelegated")) + _f(stake.get("totalPendingWithdrawal")) + prices = get_spot_prices(proxy) if balances or hype_amount > 0 else {} + + spot_usd = 0.0 + for b in balances: + coin = b.get("coin", "?") + amount = _f(b.get("total")) + price = prices.get(coin) + if price is None: + # Нет рыночной цены → оцениваем по entryNtl (стоимость входа), без неё — 0. + entry = _f(b.get("entryNtl")) + price = entry / amount if amount and entry else 0.0 + value = amount * price + spot_usd += value + details.append({"type": "Spot", "symbol": coin, "amount": amount, "value": value}) + + stake_usd = 0.0 + if hype_amount > 0: + stake_usd = hype_amount * prices.get("HYPE", 0.0) + details.append({"type": "Staked", "symbol": "HYPE", "amount": hype_amount, "value": stake_usd}) + + return { + "total_usd": perp_usd + spot_usd + stake_usd, + "perp_usd": perp_usd, + "spot_usd": spot_usd, + "stake_usd": stake_usd, + "details": details, + } diff --git a/debank_checker/api/lighter_client.py b/debank_checker/api/lighter_client.py new file mode 100644 index 0000000..5e430d7 --- /dev/null +++ b/debank_checker/api/lighter_client.py @@ -0,0 +1,73 @@ +""" +Нативный клиент Lighter (zkLighter) — проверка app-chain позиций lighter / +lighter_robinhood, которые Rabby отдаёт с чужими суммами. + +GET /api/v1/account?by=l1_address&value= + * 400 {"code":21100,"message":"account not found"} → аккаунта нет → 0. + * 200 {"accounts":[{... "total_asset_value": "...", "collateral": "..."}]} +Если схема не распознана — LighterError (fail-closed: выборка повторится). +""" +from __future__ import annotations + +import time +from typing import Any + +from debank_checker.api import http +from debank_checker.api.http import PerKeyLimiter +from debank_checker.config import APPCHAIN_RATE_PER_SEC, APPCHAIN_TIMEOUT, LIGHTER_API_URL + +_LIMITER = PerKeyLimiter(APPCHAIN_RATE_PER_SEC) + + +class LighterError(RuntimeError): + pass + + +def get_positions(address: str, proxy: str | None = None) -> dict[str, Any]: + """{"total_usd": float, "details": [...]} для адреса на Lighter.""" + addr = address.lower() + attempts, pause = http.retry_policy(proxy) + last: Exception | None = None + for attempt in range(attempts): + _LIMITER.wait(proxy) + try: + resp = http.get(LIGHTER_API_URL, proxy, params={"by": "l1_address", "value": addr}, + timeout=APPCHAIN_TIMEOUT) + if resp.status_code in (429, 502, 503, 504): + last = LighterError(f"Lighter HTTP {resp.status_code}") + time.sleep(pause if proxy else min(3.0 * (attempt + 1), 30.0)) + continue + break + except http.ProxyDead: + raise + except Exception as e: # noqa: BLE001 + last = e + time.sleep(pause * (attempt + 1)) + else: + raise LighterError(f"Lighter недоступен: {last}") + + if resp.status_code == 400: + try: + body = resp.json() + except Exception: # noqa: BLE001 + body = {} + if isinstance(body, dict) and body.get("code") == 21100: # account not found + return {"total_usd": 0.0, "details": []} + raise LighterError(f"Lighter HTTP 400: {str(body)[:120]}") + if resp.status_code != 200: + raise LighterError(f"Lighter HTTP {resp.status_code}") + + body = resp.json() + accounts = body.get("accounts") if isinstance(body, dict) else None + if not isinstance(accounts, list): + raise LighterError("Lighter: неожиданная схема ответа") + total = 0.0 + details = [] + for acc in accounts: + val = acc.get("total_asset_value", acc.get("collateral")) + if val is None: + raise LighterError("Lighter: в аккаунте нет total_asset_value/collateral") + v = float(val) + total += v + details.append({"type": "Account", "symbol": "USDC", "amount": v, "value": v}) + return {"total_usd": total, "details": details} diff --git a/debank_checker/api/onchain.py b/debank_checker/api/onchain.py new file mode 100644 index 0000000..53fae01 --- /dev/null +++ b/debank_checker/api/onchain.py @@ -0,0 +1,217 @@ +""" +On-chain верификация балансов токенов через публичные JSON-RPC. + +Зачем: с 11.09.2026 эндпоинты Rabby (total_balance, complex_app_list, +token_list) возвращают для части адресов чужие данные. Количество токена на +адресе — единственное, что можно проверить независимо и дёшево: + * нативный токен → eth_getBalance(address) + * ERC-20 → eth_call balanceOf(address) + +Источники RPC (объединяются, дубли убираются, в этом порядке): + 1. rpc.txt в корне проекта — необязательный пользовательский список + (формат: , по одному на строку); + 2. data/rpc_registry.json — встроенный реестр, проверенный живьём для всех + сетей Rabby (tools/build_rpc_registry.py), отсортирован по задержке; + 3. chainid.network/chains.json — живой реестр (кэш 7 дней в .cache/). + +Стратегия: перебираются ВСЕ RPC сети (неудачные получают короткий cooldown), +затем ещё ONCHAIN_ROUNDS раундов с паузой. Только если ни один RPC сети не +ответил — OnchainUnavailable; вызывающий код повторяет выборку целиком. +Запросы могут идти через прокси (тот же, что у Rabby), чтобы лимиты +публичных нод делились на 100 IP, а не на один. +""" +from __future__ import annotations + +import json +import threading +import time +from pathlib import Path +from typing import Any + +from debank_checker import diskcache +from debank_checker.api import http +from debank_checker.config import ONCHAIN_ROUNDS, ONCHAIN_RPC_HEDGE_SEC, ONCHAIN_RPC_TIMEOUT +from debank_checker.parallel import first_success + +CHAINS_JSON_URL = "https://chainid.network/chains.json" +_ROOT = Path(__file__).resolve().parent.parent.parent +_CHAINS_CACHE = "chainid_rpc.json" +_REGISTRY_FILE = Path(__file__).resolve().parent.parent / "data" / "rpc_registry.json" +_USER_RPC_FILE = _ROOT / "rpc.txt" +_CHAINS_TTL = 7 * 24 * 3600 + +_REGISTRY_LOCK = threading.Lock() +_REGISTRY: dict[int, list[str]] | None = None +_BAD_LOCK = threading.Lock() +_BAD_RPC: dict[tuple[str, str], float] = {} # (url, прокси) -> время, до которого не использовать +_HOST_SEM: dict[str, threading.Semaphore] = {} +_HOST_LOCK = threading.Lock() +HOST_CONCURRENCY = 6 # параллельных запросов на один RPC-хост с одного IP +BAD_COOLDOWN = 45.0 # секунд не трогать RPC после ошибки + + +class OnchainUnavailable(RuntimeError): + """Ни один RPC сети не ответил — проверить нельзя.""" + + +def _read_user_rpc() -> dict[int, list[str]]: + out: dict[int, list[str]] = {} + try: + if not _USER_RPC_FILE.exists(): + return out + for line in _USER_RPC_FILE.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.replace(",", " ").split() + if len(parts) >= 2 and parts[0].isdigit() and parts[1].startswith("http"): + out.setdefault(int(parts[0]), []).append(parts[1]) + except OSError: + pass + return out + + +def _read_builtin() -> dict[int, list[str]]: + try: + data = json.loads(_REGISTRY_FILE.read_text(encoding="utf-8")) + return {int(k): list(v) for k, v in data.items()} + except Exception: + return {} + + +def _read_chainid_network() -> dict[int, list[str]]: + """HTTPS-RPC из chainid.network (кэш .cache/chainid_rpc.json на 7 дней).""" + cached = diskcache.load(_CHAINS_CACHE, ttl=_CHAINS_TTL) + if isinstance(cached, dict): + return {int(k): list(v) for k, v in cached.items()} + try: + resp = http.get(CHAINS_JSON_URL, None, timeout=20) + data = resp.json() if resp.status_code == 200 else None + except Exception: # noqa: BLE001 + data = None + out: dict[int, list[str]] = {} + for c in data or []: + try: + cid = int(c.get("chainId")) + except (TypeError, ValueError): + continue + urls = [u for u in c.get("rpc", []) if isinstance(u, str) and u.startswith("https://") and "${" not in u] + if urls: + out[cid] = urls + if out: + diskcache.save(_CHAINS_CACHE, out) + return out + + +def _load_registry() -> dict[int, list[str]]: + global _REGISTRY + with _REGISTRY_LOCK: + if _REGISTRY is not None: + return _REGISTRY + reg: dict[int, list[str]] = {} + for source in (_read_user_rpc(), _read_builtin(), _read_chainid_network()): + for cid, urls in source.items(): + lst = reg.setdefault(int(cid), []) + for u in urls: + if u not in lst: + lst.append(u) + _REGISTRY = reg + return reg + + +def rpc_urls(chain_id: int) -> list[str]: + return list(_load_registry().get(int(chain_id), [])) + + +def _sem(url: str, proxy: str | None) -> threading.Semaphore: + """Ограничение параллельности на пару (RPC-хост, исходящий IP).""" + key = url.split("/")[2] + "|" + (proxy or "direct") + with _HOST_LOCK: + s = _HOST_SEM.get(key) + if s is None: + s = _HOST_SEM[key] = threading.Semaphore(HOST_CONCURRENCY) + return s + + +def _post(url: str, payload: dict, proxy: str | None) -> Any: + with _sem(url, proxy): + resp = http.post(url, proxy, json=payload, timeout=ONCHAIN_RPC_TIMEOUT) + if resp.status_code != 200: + raise RuntimeError(f"HTTP {resp.status_code}") + body = resp.json() + if isinstance(body, dict) and body.get("error"): + raise RuntimeError(str(body["error"])[:120]) + if not isinstance(body, dict) or "result" not in body: + raise RuntimeError("нет result") + return body["result"] + + +def _call(chain_id: int, method: str, params: list, proxy: str | None = None) -> Any: + """Исчерпывающий перебор всех RPC сети, ONCHAIN_ROUNDS раундов. + + Ноды опрашиваются по очереди, но с хеджем: упавшая нода сразу сменяется + следующей, а не ответившая за ONCHAIN_RPC_HEDGE_SEC дублируется следующей + параллельно — берётся первый ответ. + """ + urls = rpc_urls(chain_id) + if not urls: + raise OnchainUnavailable(f"нет публичного RPC для chain_id={chain_id}") + payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params} + errors: list[str] = [] + + def attempt(url: str) -> Any: + def run() -> Any: + try: + return _post(url, payload, proxy) + except http.ProxyDead: + raise + except Exception as e: # noqa: BLE001 + errors.append(f"{url.split('/')[2]}: {str(e)[:50]}") + with _BAD_LOCK: + _BAD_RPC[(url, proxy or "")] = time.time() + BAD_COOLDOWN + raise + return run + + for rnd in range(max(1, ONCHAIN_ROUNDS)): + now = time.time() + with _BAD_LOCK: + order = [u for u in urls if _BAD_RPC.get((u, proxy or ""), 0) <= now] or list(urls) + try: + return first_success([attempt(u) for u in order], ONCHAIN_RPC_HEDGE_SEC) + except http.ProxyDead: + raise + except Exception: # noqa: BLE001 + pass + if rnd + 1 < ONCHAIN_ROUNDS: + time.sleep(1.5 * (rnd + 1)) + raise OnchainUnavailable(f"RPC chain_id={chain_id}: все {len(urls)} нод недоступны: " + "; ".join(errors[-3:])) + + +def _hex_to_int(x: Any) -> int: + if isinstance(x, str) and x.startswith("0x"): + return int(x, 16) if len(x) > 2 else 0 + raise RuntimeError(f"неожиданный ответ RPC: {x!r}") + + +def native_balance(chain_id: int, address: str, proxy: str | None = None) -> int: + return _hex_to_int(_call(chain_id, "eth_getBalance", [address, "latest"], proxy)) + + +def erc20_balance(chain_id: int, token: str, address: str, proxy: str | None = None) -> int: + data = "0x70a08231" + address[2:].lower().rjust(64, "0") + res = _call(chain_id, "eth_call", [{"to": token, "data": data}, "latest"], proxy) + if res in ("0x", "", None): + return 0 + return _hex_to_int(res) + + +def token_amount(chain_id: int, token_id: str, address: str, decimals: int, is_native: bool, + proxy: str | None = None) -> float: + """Количество токена на адресе по данным сети. + + Бросает OnchainUnavailable, если ни один RPC сети не ответил. Повторные + проверки одного токена в выборках кошелька дедуплицирует checker. + """ + raw = (native_balance(chain_id, address, proxy) if is_native + else erc20_balance(chain_id, token_id, address, proxy)) + return raw / (10 ** int(decimals)) diff --git a/debank_checker/api/polymarket_client.py b/debank_checker/api/polymarket_client.py new file mode 100644 index 0000000..c38bc54 --- /dev/null +++ b/debank_checker/api/polymarket_client.py @@ -0,0 +1,115 @@ +""" +Нативная проверка позиций Polymarket, которые Rabby (complex_app_list, +app id `polymarket`) с 11.09.2026 отдаёт с чужими суммами. + +Polymarket хранит позиции не на EOA, а на proxy-кошельке пользователя +(Gnosis Safe, фабрика 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b на Polygon, +`computeProxyAddress(address)` — селектор 0xd600539a). Адрес детерминирован +(CREATE2), поэтому кэшируется на диске навсегда (.cache/polymarket_proxy.json). +Стоимость позиций — публичный data-api: GET /value?user=, плюс +наличные USDC.e на proxy (balanceOf); оба запроса идут параллельно. + +Любая ошибка → PolymarketError (fail-closed: выборка повторится). +""" +from __future__ import annotations + +import time +from typing import Any + +from debank_checker.api import http, onchain +from debank_checker.api.http import PerKeyLimiter +from debank_checker.config import APPCHAIN_RATE_PER_SEC, APPCHAIN_TIMEOUT +from debank_checker.diskcache import PersistentDict +from debank_checker.parallel import run_parallel, unwrap + +_LIMITER = PerKeyLimiter(APPCHAIN_RATE_PER_SEC) +_PROXY_WALLETS = PersistentDict("polymarket_proxy.json") + +POLYGON_CHAIN_ID = 137 +SAFE_FACTORY = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b" +COMPUTE_PROXY_SELECTOR = "0xd600539a" # keccak256("computeProxyAddress(address)")[:4] +USDC_E = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" +DATA_API = "https://data-api.polymarket.com" + + +class PolymarketError(RuntimeError): + pass + + +def proxy_wallet(address: str, proxy: str | None = None) -> str | None: + """Адрес Polymarket-proxy для EOA (None — нулевой адрес).""" + addr = address.lower() + if addr in _PROXY_WALLETS: + return _PROXY_WALLETS.get(addr) + data = COMPUTE_PROXY_SELECTOR + addr[2:].rjust(64, "0") + try: + res = onchain._call(POLYGON_CHAIN_ID, "eth_call", [{"to": SAFE_FACTORY, "data": data}, "latest"], proxy) + except onchain.OnchainUnavailable as e: + raise PolymarketError(f"Polygon RPC недоступен: {e}") + if not isinstance(res, str) or len(res) < 42: + raise PolymarketError(f"computeProxyAddress: неожиданный ответ {res!r}") + pw = "0x" + res[-40:] + result = None if int(pw, 16) == 0 else pw + _PROXY_WALLETS.set(addr, result) + return result + + +def _get_json(path: str, params: dict, proxy: str | None) -> Any: + attempts, pause = http.retry_policy(proxy) + last: Exception | None = None + for attempt in range(attempts): + _LIMITER.wait(proxy) + try: + resp = http.get(DATA_API + path, proxy, params=params, timeout=APPCHAIN_TIMEOUT) + if resp.status_code == 429: + time.sleep(pause if proxy else min(http.retry_after(resp, 3.0 * (attempt + 1)), 30.0)) + last = PolymarketError("HTTP 429 from Polymarket") + continue + if resp.status_code != 200: + raise PolymarketError(f"Polymarket HTTP {resp.status_code} ({path})") + return resp.json() + except (PolymarketError, http.ProxyDead): + raise + except Exception as e: # noqa: BLE001 + last = e + time.sleep(pause * (attempt + 1)) + raise PolymarketError(f"Polymarket недоступен: {last}") + + +def _cash(pw: str, proxy: str | None) -> float: + try: + return onchain.token_amount(POLYGON_CHAIN_ID, USDC_E, pw, 6, False, proxy) + except onchain.OnchainUnavailable as e: + raise PolymarketError(f"баланс USDC.e на proxy не проверен: {e}") + + +def get_positions(address: str, proxy: str | None = None) -> dict[str, Any]: + """{"total_usd", "details": [...], "proxy_wallet"} для EOA.""" + addr = address.lower() + pw = proxy_wallet(addr, proxy) + if not pw: + return {"total_usd": 0.0, "details": [], "proxy_wallet": None} + + r = run_parallel({ + "value": lambda: _get_json("/value", {"user": pw}, proxy), + "cash": lambda: _cash(pw, proxy), + }) + value, cash = unwrap(r["value"]), unwrap(r["cash"]) + if not isinstance(value, list): + raise PolymarketError("value: неожиданная схема") + pos_usd = 0.0 + for row in value: + try: + pos_usd += float(row.get("value") or 0) + except (TypeError, ValueError): + raise PolymarketError(f"value: нечисловое значение {row!r}") + + details = [] + if pos_usd > 0: + positions = _get_json("/positions", {"user": pw, "sizeThreshold": 0}, proxy) + for p in positions if isinstance(positions, list) else []: + details.append({"type": "Position", "symbol": str(p.get("title") or p.get("asset") or "?")[:40], + "amount": float(p.get("size") or 0), "value": float(p.get("currentValue") or 0)}) + if cash > 0: + details.append({"type": "Cash", "symbol": "USDC.e", "amount": cash, "value": cash}) + return {"total_usd": pos_usd + cash, "details": details, "proxy_wallet": pw} diff --git a/debank_checker/api/rabby_client.py b/debank_checker/api/rabby_client.py index c7da836..0027390 100644 --- a/debank_checker/api/rabby_client.py +++ b/debank_checker/api/rabby_client.py @@ -19,8 +19,13 @@ live-API: старые заголовки → 429, заголовки клиента → 200; расширение делает ~10 req/s без единого 429). -Прокси обязателен. +Прокси обязателен (прямой режим — только явно, DBC_DIRECT=1). + +ВНИМАНИЕ: с 11.09.2026 total_balance и complex_app_list для части адресов +возвращают чужие данные — см. config.py, checker.py. Клиент отдаёт ответы +как есть; проверка — в checker. """ +from __future__ import annotations import hashlib import hmac as hmac_lib @@ -29,15 +34,26 @@ import time from typing import Any -import curl_cffi.requests as cffi_requests - +from debank_checker import config as _cfg +from debank_checker.api import http +from debank_checker.api.http import RateLimiter from debank_checker.config import ( RABBY_API_KEY_INIT, RABBY_API_KEY_INIT_TIME, + NFT_REQUEST_TIMEOUT, RABBY_CLIENT_VERSION, REQUEST_TIMEOUT, ) + +_DIRECT_LIMITER = RateLimiter(_cfg.RABBY_DIRECT_RATE_PER_SEC) + + +def direct_allowed() -> bool: + """Разрешена ли работа без прокси (config.ALLOW_DIRECT / DBC_DIRECT=1).""" + return bool(_cfg.ALLOW_DIRECT) + + API_BASE = "https://api.rabby.io" SIGN_PREFIX = "rabby-api\n" # Алфавит nonce — как в клиенте Rabby (sic: без Y и j). @@ -113,18 +129,13 @@ def sign_request( class RabbyClient: - """Клиент Rabby API. Прокси обязателен.""" + """Клиент Rabby API. Прокси обязателен, если не разрешён прямой режим.""" - def __init__(self, proxy: str, impersonate: str = "chrome124"): - if not proxy: - raise ValueError("Прокси обязателен для Rabby API") + def __init__(self, proxy: str | None): + if not proxy and not direct_allowed(): + raise ValueError("Прокси обязателен для Rabby API (или DBC_DIRECT=1 для прямого режима)") self._api_key, self._key_time = _current_key() - self._impersonate = impersonate - proxies = {"https": proxy, "http": proxy} - self._session = cffi_requests.Session( - impersonate=impersonate, - proxies=proxies, - ) + self._proxy = proxy or None def _build_headers(self, params: dict, method: str, path: str) -> dict: # Состав и кейсинг — строго по HAR расширения Rabby (см. докстринг @@ -150,36 +161,46 @@ def _build_headers(self, params: dict, method: str, path: str) -> dict: } def _get(self, path: str, params: dict | None = None, timeout: float | None = None) -> Any: + """GET с подписью. + + Через прокси 429/5xx сразу уходят наверх: check_wallet повторит выборку + через другой прокси (быстрее, чем ждать на заблокированном IP). В прямом + режиме (один IP) — до RABBY_RETRY_429 повторов с паузой 3·6·12 с + (или Retry-After). + """ params = params or {} - headers = self._build_headers(params, "GET", path) - resp = self._session.get( - API_BASE + path, - params=params, - headers=headers, - timeout=timeout or REQUEST_TIMEOUT, - ) - - # Ротация ключа читается ДО raise_for_status: сервер может выдать - # новый ключ и вместе с ошибкой (429/403) — раньше он терялся. - new_key = resp.headers.get("x-set-api-key") - if new_key and new_key != self._api_key: - self._key_time = _rotate_key(new_key) - self._api_key = new_key - - resp.raise_for_status() - - data = resp.json() - if isinstance(data, dict) and "data" in data and set(data.keys()) <= {"data", "error_code"}: - return data["data"] - return data + retries = int(_cfg.RABBY_RETRY_429) if self._proxy is None else 0 + for attempt in range(retries + 1): + headers = self._build_headers(params, "GET", path) + if self._proxy is None: + _DIRECT_LIMITER.wait() + resp = http.get(API_BASE + path, self._proxy, params=params, headers=headers, + timeout=timeout or REQUEST_TIMEOUT) + + # Ротация ключа читается ДО raise_for_status: сервер может выдать + # новый ключ и вместе с ошибкой (429/403). + new_key = resp.headers.get("x-set-api-key") + if new_key and new_key != self._api_key: + self._key_time = _rotate_key(new_key) + self._api_key = new_key + + if resp.status_code in (429, 502, 503, 504) and attempt < retries: + time.sleep(min(http.retry_after(resp, 3.0 * (2 ** attempt)), 30.0)) + continue + + resp.raise_for_status() + + data = resp.json() + if isinstance(data, dict) and "data" in data and set(data.keys()) <= {"data", "error_code"}: + return data["data"] + return data + raise RuntimeError("unreachable") def get_total_balance(self, address: str, is_core: bool = True) -> dict: - """Итоговый баланс кошелька — АВТОРИТЕТНЫЙ агрегат от сервера. + """Агрегат баланса: {total_usd_value, chain_list: [{id, usd_value}]}. - Возвращает dict: {total_usd_value: float, chain_list: [{id, usd_value, ...}]}. - is_core=true — только проверенные (core) токены, отсекает скам. - Это ключевой запрос миграции: total берётся отсюда напрямую, без - ручного суммирования → устраняет фантомные балансы в итоговой сумме. + НЕ источник итога (бывает заражён чужими суммами) — только контроль и + подсказка, в каких сетях кэш токенов мог устареть. См. checker.py. """ params = {"id": address.lower(), "is_core": "true" if is_core else "false"} result = self._get("/v1/user/total_balance", params) @@ -197,12 +218,7 @@ def get_cache_token_list(self, address: str) -> list: return result if isinstance(result, list) else [] def get_token_list(self, address: str, chain_id: str, is_all: bool = False) -> list: - """Список токенов кошелька в конкретной сети. - - Rabby не отдаёт токены всех сетей одним запросом (в отличие от DeBank - /token/cache_balance_list), поэтому запрашиваем по каждой сети отдельно. - is_all=false → только проверенные/core токены (нужный нам режим). - """ + """Свежий список токенов кошелька в одной сети (is_all=false → только core).""" params = { "id": address.lower(), "chain_id": chain_id, @@ -230,7 +246,12 @@ def get_collection_list(self, address: str, is_all: bool = True) -> list: Заменяет пару DeBank-запросов /nft/used_chains + /nft/collection_list. """ params = {"id": address.lower(), "is_all": "true" if is_all else "false"} - result = self._get("/v1/user/collection_list", params) + result = self._get("/v1/user/collection_list", params, timeout=NFT_REQUEST_TIMEOUT) + return result if isinstance(result, list) else [] + + def get_chain_list(self) -> list: + """Список сетей Rabby: id (строковый), community_id (EVM chain id), native_token_id.""" + result = self._get("/v1/chain/list") return result if isinstance(result, list) else [] def get_used_chain_list(self, address: str) -> list: diff --git a/debank_checker/checker.py b/debank_checker/checker.py index 1078211..c7a5e21 100644 --- a/debank_checker/checker.py +++ b/debank_checker/checker.py @@ -1,12 +1,43 @@ """ -Проверка балансов кошельков через Rabby API: tokens + protocols + NFT +Проверка балансов кошельков через Rabby API: tokens + protocols + NFT. + +Почему итог НЕ берётся из агрегата Rabby — см. блок «Защита от фантомных +балансов» в config.py. Кратко: с 11.09.2026 /v1/user/total_balance и +/v1/user/complex_app_list для части адресов возвращают чужие суммы; список +токенов при этом стабилен и совпадает с независимыми источниками. + +Итог одной выборки = токены кошелька (core, не скам, подтверждены on-chain) + + EVM-позиции протоколов (пересчёт по asset_token_list) + + app-chain позиции, подтверждённые нативными API. +Агрегат Rabby — только контроль (aggregate_agrees / примечание). +Баланс принимается, когда CORROBORATION_MIN_AGREE выборок сошлись по итогу. + +Скорость: внутри выборки все независимые запросы идут параллельно; выборки +одного кошелька идут одновременно через разные прокси (плюс «хедж» при +зависании). Данные, не зависящие от ответа Rabby (нативные app-chain API, +on-chain количества, NFT), запрашиваются один раз на кошелёк. """ +from __future__ import annotations +import json +import sys +import threading import time +from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait +from datetime import datetime +from pathlib import Path from typing import Any -from debank_checker.api.rabby_client import RabbyClient +from debank_checker import diskcache +from debank_checker.api.http import ProxyDead +from debank_checker.api.rabby_client import RabbyClient, direct_allowed from debank_checker.config import ( + AGGREGATE_OPTIONAL, + APPCHAIN_VERIFY, + APPCHAIN_VIA_PROXY, + CHAIN_REFETCH_MAX, + COMPONENT_TOL_ABS, + COMPONENT_TOL_REL, CORROBORATION_ABS_TOL, CORROBORATION_ENABLED, CORROBORATION_MAX_FETCHES, @@ -14,91 +45,277 @@ CORROBORATION_REL_TOL, DEBUG, MIN_VALUE_DISPLAY, + ONCHAIN_AMOUNT_TOL, + ONCHAIN_MIN_USD, + ONCHAIN_VERIFY, RABBY_IS_CORE, + RAW_LOG_ENABLED, + RAW_LOG_MAX_BYTES, + RETRY_429_BACKOFF_SEC, RETRY_ATTEMPTS, + RETRY_BACKOFF_SEC, + RETRY_PROXY_BACKOFF_SEC, + NFT_ATTEMPTS, + NFT_BACKGROUND_WORKERS, + NFT_HEDGE_SEC, + SNAPSHOT_HEDGE_SEC, + TAINT_ABS_USD, + TAINT_REL, ) +from debank_checker.parallel import Memo, first_success, run_parallel, unwrap from debank_checker.proxy.manager import ProxyManager - - -def _mask_proxy(proxy: str | None) -> str: - """Маскирует прокси для лога (оставляет ip:port).""" - if not proxy: - return "—" - try: - if "@" in proxy: - return proxy.split("@")[-1] - return proxy - except Exception: - return "?" - - -def _safe_position_value(item: dict) -> float: - """ - Вычисляет стоимость позиции протокола, защищаясь от фантомных данных API. - - Логика: - - Пересчитывает стоимость из asset_token_list (фильтруя скам-токены). - - Если asset_token_list пустой или все токены скам → возвращает 0. - - Использует min(api_value, recalc_value): - * Для lending: api_value (залог−долг) < recalc_value (только залог) → берём api_value. - * Для farming/common: recalc_value ≈ api_value → результат корректный. - * Для фантомной позиции: recalc_value = 0 → возвращаем 0. - """ - api_value = max(0.0, item.get("stats", {}).get("net_usd_value", 0)) - - asset_tokens = item.get("asset_token_list", []) - if not asset_tokens: - return 0.0 # нет токенов — фантомная позиция - - recalc = 0.0 - for t in asset_tokens: - value = t.get("price", 0) * t.get("amount", 0) - if t.get("is_verified", True) and not t.get("is_scam", False): - recalc += value - - recalc = max(0.0, recalc) - if recalc == 0.0: - return 0.0 # все токены в позиции — скам - - return min(api_value, recalc) - - -def _write_log(address: str, total_usd: float, tokens_usd: float, - protocols_usd: float, tokens: list, portfolio: list) -> None: - """Пишет лог в logs.txt в корневой директории проекта.""" - import json - from pathlib import Path - from datetime import datetime - - log_path = Path(__file__).parent.parent / "logs.txt" - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - with open(log_path, "a", encoding="utf-8") as f: - f.write(f"\n{'=' * 60}\n") - f.write(f"[{timestamp}] {address}\n") - f.write(f" total_usd={total_usd:.2f} tokens_usd={tokens_usd:.2f}" - f" protocols_usd={protocols_usd:.2f}\n") - if total_usd > 1_000: - f.write(" [!] АНОМАЛЬНЫЙ БАЛАНС — сохраняю сырые данные\n") - f.write(" --- TOKENS ---\n") - f.write(json.dumps(tokens, ensure_ascii=False, indent=2)) - f.write("\n --- PORTFOLIO ---\n") - f.write(json.dumps(portfolio, ensure_ascii=False, indent=2)) - f.write("\n") - - + +# App-chain приложения Rabby, для которых есть нативная проверка. +APPCHAIN_NATIVE = { + "hyperliquid": "hyperliquid", + "lighter": "lighter", + "lighter_robinhood": "lighter", + "polymarket": "polymarket", +} +# Ключ нативного источника → (подпись в protocols_data, chain для экспорта). +APPCHAIN_LABELS = { + "hyperliquid": ("Hyperliquid", "hyperliquid"), + "lighter": ("Lighter", "lighter"), + "polymarket": ("Polymarket", "matic"), +} + +_CHAINS_CACHE = "rabby_chains.json" +_CHAINS_TTL = 24 * 3600 +_CHAIN_MEMO = Memo() +_LOG_LOCK = threading.Lock() +_NFT_POOL = ThreadPoolExecutor(max_workers=NFT_BACKGROUND_WORKERS, thread_name_prefix="nft") +_NFT_LOCK = threading.Lock() +_NFT_PENDING: set[Future] = set() + + +# ---------------------------------------------------------------- сети Rabby + +def _chain_map(client: Any) -> dict[str, dict[str, Any]]: + """rabby chain id → {"evm": community_id, "native": native_token_id}. + + Кэш на процесс (singleflight) и на диске на сутки. При сбое — {} (токены + не пройдут on-chain проверку, выборка повторится). + """ + try: + return _CHAIN_MEMO.get("chains", lambda: _load_chain_map(client)) + except Exception: # noqa: BLE001 + return {} + + +def _load_chain_map(client: Any) -> dict[str, dict[str, Any]]: + chains = diskcache.load(_CHAINS_CACHE, ttl=_CHAINS_TTL) + fresh = not isinstance(chains, list) or not chains + if fresh: + chains = client.get_chain_list() + m: dict[str, dict[str, Any]] = {} + for c in chains or []: + try: + m[str(c["id"])] = {"evm": int(c["community_id"]), "native": str(c.get("native_token_id") or c["id"])} + except (KeyError, TypeError, ValueError): + continue + if not m: + raise RuntimeError("пустой список сетей Rabby") + if fresh: + diskcache.save(_CHAINS_CACHE, [{"id": k, "community_id": v["evm"], "native_token_id": v["native"]} + for k, v in m.items()]) + return m + + +# ---------------------------------------------------------------- on-chain + +class OnchainCheckFailed(RuntimeError): + """Токен нельзя подтвердить on-chain (нет RPC сети) — выборка не засчитывается.""" + + +def _onchain_verify_token(token: dict, address: str, chain_map: dict[str, dict[str, Any]], + proxy: str | None = None) -> float: + """Количество токена по данным сети. Бросает OnchainCheckFailed. Тесты подменяют.""" + from debank_checker.api import onchain + + chain = str(token.get("chain") or "") + info = chain_map.get(chain) + if not info: + raise OnchainCheckFailed(f"сеть {chain!r} не сопоставлена с EVM chain id") + token_id = str(token.get("id") or "") + is_native = (token_id == chain or token_id == info["native"] or not token_id.startswith("0x")) + try: + decimals = int(token.get("decimals") or 18) + except (TypeError, ValueError): + decimals = 18 + try: + return onchain.token_amount(info["evm"], token_id, address, decimals, is_native, + proxy if APPCHAIN_VIA_PROXY else None) + except onchain.OnchainUnavailable as e: + raise OnchainCheckFailed(str(e)) + + +def _token_key(t: dict) -> tuple[str, str]: + """Идентификатор токена: (сеть, адрес контракта или символ).""" + return str(t.get("chain") or ""), str(t.get("id") or t.get("symbol") or "").lower() + + +def _verify_tokens(tokens: list[dict], address: str, chain_map: dict[str, dict[str, Any]], + notes: list[str], require: bool, proxy: str | None = None, + memo: Memo | None = None, stats: dict[str, Any] | None = None) -> list[dict]: + """On-chain проверка токенов с оценкой ≥ ONCHAIN_MIN_USD (все токены — параллельно). + + Фантом (on-chain 0) отбрасывается, расхождение количества исправляется по + сети. Если сеть недоступна (все RPC молчат) — OnchainCheckFailed: выборка + целиком не засчитывается и повторяется; ничего непроверенного в итог не + попадает. require=True — кандидаты из по-сетевого token_list: мелкие + (< ONCHAIN_MIN_USD) не принимаются вовсе. memo — общий кэш кошелька: + одинаковый токен в разных выборках проверяется один раз. + + stats (если передан) пополняется: "rejected_usd" — стоимость, которую + on-chain проверка сняла (фантомы + завышенные количества), "confirmed" — + ключи токенов, подтверждённых на цепочке на ≥ ONCHAIN_MIN_USD. + """ + if not ONCHAIN_VERIFY: + return list(tokens) + memo = memo if memo is not None else Memo() + todo = [(i, t) for i, t in enumerate(tokens) if _token_value(t) >= ONCHAIN_MIN_USD] + + def check(t: dict) -> Any: + return lambda: memo.get(("onchain",) + _token_key(t), + lambda: _onchain_verify_token(t, address, chain_map, proxy)) + + amounts = run_parallel({i: check(t) for i, t in todo}) + out: list[dict] = [] + for i, t in enumerate(tokens): + value = _token_value(t) + if i not in amounts: + if not require: + out.append(t) + continue + label = f"{t.get('symbol', '?')}@{t.get('chain', '?')}" + onchain_amount = amounts[i] + if isinstance(onchain_amount, OnchainCheckFailed): + raise OnchainCheckFailed(f"{label} ${value:.2f}: {onchain_amount}") + onchain_amount = unwrap(onchain_amount) + amount = float(t.get("amount") or 0) + price = float(t.get("price") or 0) + if stats is not None: + stats["rejected_usd"] = stats.get("rejected_usd", 0.0) + max(0.0, amount - max(onchain_amount, 0.0)) * price + if onchain_amount * price >= ONCHAIN_MIN_USD: + stats.setdefault("confirmed", set()).add(_token_key(t)) + if onchain_amount <= 0: + notes.append(f"{label} ${value:.2f}: on-chain баланс 0 — фантомный токен отброшен") + continue + if abs(onchain_amount - amount) > ONCHAIN_AMOUNT_TOL * max(abs(amount), abs(onchain_amount)): + fixed = dict(t) + fixed["amount"] = onchain_amount + notes.append(f"{label}: количество {amount:.6g} → {onchain_amount:.6g} по данным сети") + out.append(fixed) + else: + out.append(t) + return out + + +# ---------------------------------------------------------------- helpers + +def _mask_proxy(proxy: str | None) -> str: + """Маскирует прокси для лога (оставляет ip:port).""" + if not proxy: + return "direct" + return proxy.split("@")[-1] + + +def _token_ok(t: dict) -> bool: + return (t.get("is_verified", True) and not t.get("is_scam", False) + and not t.get("is_suspicious", False) + and (not RABBY_IS_CORE or t.get("is_core", True))) + + +def _token_value(t: dict) -> float: + try: + return float(t.get("price") or 0) * float(t.get("amount") or 0) + except (TypeError, ValueError): + return 0.0 + + +def _close(a: float, b: float, abs_tol: float = COMPONENT_TOL_ABS, rel_tol: float = COMPONENT_TOL_REL) -> bool: + return abs(a - b) <= max(abs_tol, rel_tol * max(abs(a), abs(b))) + + +def _by_chain(tokens: list[dict]) -> dict[str, float]: + """Сумма токенов по сетям (USD).""" + out: dict[str, float] = {} + for t in tokens: + out[t.get("chain", "")] = out.get(t.get("chain", ""), 0.0) + _token_value(t) + return out + + +def _safe_position_value(item: dict) -> float: + """ + Стоимость EVM-позиции протокола с защитой от фантомных данных. + + - Пересчитывает стоимость из asset_token_list (без скам-токенов). + - Пустой asset_token_list или все токены скам → 0. + - min(api_value, recalc): для lending api_value (залог−долг) < recalc; + для farming/common ≈ равны; фантом с recalc=0 → 0. + """ + api_value = max(0.0, float((item.get("stats") or {}).get("net_usd_value") or 0)) + + asset_tokens = item.get("asset_token_list") or [] + if not asset_tokens: + return 0.0 + + recalc = 0.0 + for t in asset_tokens: + if t.get("is_verified", True) and not t.get("is_scam", False): + recalc += _token_value(t) + + recalc = max(0.0, recalc) + if recalc == 0.0: + return 0.0 + + return min(api_value, recalc) + + +def _item_chain(item: dict) -> str: + """Сеть позиции по токенам; '' для app-chain (Hyperliquid, Lighter, …).""" + detail = item.get("detail") or {} + for tk in (detail.get("supply_token_list") or []) + (item.get("asset_token_list") or []): + if tk.get("chain"): + return str(tk["chain"]) + return "" + + +def _write_log(address: str, snap: dict[str, Any], raw: dict[str, Any] | None) -> None: + """Пишет запись о выборке в logs.txt в корне проекта.""" + if not RAW_LOG_ENABLED: + return + log_path = Path(__file__).parent.parent / "logs.txt" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + agg = snap.get("aggregate_usd") + lines = [ + f"\n{'=' * 60}\n", + f"[{timestamp}] {address}\n", + f" total_usd={snap['total_usd']:.2f} tokens_usd={snap['tokens_usd']:.2f}" + f" protocols_usd={snap['protocols_usd']:.2f}" + f" aggregate_usd={('n/a' if agg is None else f'{agg:.2f}')}" + f" unverified_usd={snap['unverified_usd']:.2f}\n", + ] + lines += [f" [!] {n}\n" for n in snap.get("notes", [])] + if raw: + blob = json.dumps(raw, ensure_ascii=False) + if len(blob) > RAW_LOG_MAX_BYTES: + blob = blob[:RAW_LOG_MAX_BYTES] + f"... <обрезано, {len(blob)} байт>" + lines.append(" --- RAW ---\n" + blob + "\n") + try: + with _LOG_LOCK, open(log_path, "a", encoding="utf-8") as f: + f.write("".join(lines)) + except OSError: + pass + + def _agree(a: float, b: float) -> bool: """Две суммы считаются согласованными в пределах относ./абс. допуска.""" - return abs(a - b) <= max(CORROBORATION_ABS_TOL, - CORROBORATION_REL_TOL * max(abs(a), abs(b))) + return _close(a, b, CORROBORATION_ABS_TOL, CORROBORATION_REL_TOL) def _largest_agreeing_cluster(snaps: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Наибольшая группа выборок, согласованных по total_usd. - - При равенстве размеров выбирается группа с НАИМЕНЬШИМ значением — фантом - всегда завышает баланс, поэтому консервативный выбор защищает от инфляции. - """ + """Наибольшая группа выборок, согласованных по total_usd (при равенстве — меньшая сумма).""" best: list[dict[str, Any]] = [] for anchor in snaps: cluster = [s for s in snaps if _agree(s["total_usd"], anchor["total_usd"])] @@ -115,91 +332,175 @@ def _rep(cluster: list[dict[str, Any]]) -> dict[str, Any]: def _build_tokens_data(tokens: list[dict]) -> list[dict]: - """Из сырых токенов Rabby строит tokens_data (та же схема, что у DeBank).""" - return [ - { + """Из сырых токенов Rabby строит tokens_data для экспорта.""" + out = [] + for t in tokens: + value = round(_token_value(t), 2) + if value < MIN_VALUE_DISPLAY: + continue + out.append({ "logo": t.get("logo_url") or "", "symbol": t.get("symbol", "?"), "chain": t.get("chain", "?"), "amount": t.get("amount", 0), "price": t.get("price", 0), - "value": round(t.get("price", 0) * t.get("amount", 0), 2), - } - for t in tokens - if round(t.get("price", 0) * t.get("amount", 0), 2) >= MIN_VALUE_DISPLAY - ] + "value": value, + }) + return out + + +def _appchain_positions(address: str, proxy: str | None) -> dict[str, dict[str, Any]]: + """Позиции адреса на app-chain через нативные API (все три — параллельно). + Любая ошибка API → исключение (fail-closed): выборка повторяется целиком. + Тесты подменяют эту функцию. + """ + from debank_checker.api import hyperliquid_client, lighter_client, polymarket_client + + px = proxy if APPCHAIN_VIA_PROXY else None + r = run_parallel({ + "hyperliquid": lambda: hyperliquid_client.get_positions(address, px), + "lighter": lambda: lighter_client.get_positions(address, px), + "polymarket": lambda: polymarket_client.get_positions(address, px), + }) + return {k: unwrap(v) for k, v in r.items()} -def _fetch_snapshot(address: str, proxy: str) -> dict[str, Any]: - """Одна выборка баланса кошелька через Rabby API. - Ключевое: total_usd берётся из АВТОРИТЕТНОГО агрегата - /v1/user/total_balance (total_usd_value уже включает и токены, и DeFi), - а НЕ считается вручную суммой отдельных запросов. +def _fetch_nfts(client: Any, address: str) -> list[dict]: + """NFT-коллекции одним запросом (is_all=true), без скама/непроверенных.""" + nft_data: list[dict] = [] + for col in client.get_collection_list(address, is_all=True): + if col.get("is_scam") or col.get("is_suspicious"): + continue + if RABBY_IS_CORE and col.get("is_verified") is False: + continue + amount = col.get("amount") or len(col.get("nft_list") or []) + if not amount: + continue + nft_data.append({"name": col.get("name", "?"), "chain": col.get("chain") or "", "amount": amount}) + nft_data.sort(key=lambda x: (x["chain"], x["name"])) + return nft_data - tokens_data / protocols_data / nft_data собираются как детализация для - экспорта; result-dict совместим со старой DeBank-схемой. + +def _aggregate_optional(err: Exception, proxy: str | None) -> bool: + """Можно ли продолжить выборку без агрегата: 403 (бан эндпоинта для IP), + а в прямом режиме ещё и 429. Через прокси 429 → выборка повторится с + другого IP, чтобы не терять сверку по сетям.""" + if not AGGREGATE_OPTIONAL: + return False + s = str(err) + return "403" in s or "Forbidden" in s or (proxy is None and "429" in s) + + +# ---------------------------------------------------------------- выборка + +def _fetch_snapshot(address: str, proxy: str | None, shared: Memo | None = None, + with_nft: bool = True) -> dict[str, Any]: + """Одна выборка баланса кошелька. + + total_usd = tokens_usd + protocols_usd, где protocols_usd — только + проверенные позиции. Агрегат Rabby (total_usd_value) сохраняется в + aggregate_usd и сравнивается с итогом, но НЕ используется как итог. + + shared — кэш кошелька (нативные app-chain позиции и on-chain количества + общие для всех выборок). with_nft=False — NFT запрашивает check_wallet. """ + shared = shared if shared is not None else Memo() client = RabbyClient(proxy=proxy) - - # 1) Авторитетный итог + разбивка по сетям. - total = client.get_total_balance(address, is_core=RABBY_IS_CORE) - total_usd = float(total.get("total_usd_value") or 0.0) - chain_list = total.get("chain_list", []) or [] - nonzero_chains = [ - c.get("id") for c in chain_list - if c.get("id") and (c.get("usd_value") or 0) > 0 - ] + notes: list[str] = [] + raw_evidence: dict[str, Any] = {} + + # 1) Все независимые запросы — одновременно. + calls: dict[str, Any] = { + "agg": lambda: client.get_total_balance(address, is_core=RABBY_IS_CORE), + "tokens": lambda: client.get_cache_token_list(address), + "apps": lambda: client.get_complex_app_list(address), + } + if ONCHAIN_VERIFY: + calls["chain_map"] = lambda: _chain_map(client) + if APPCHAIN_VERIFY: + calls["native"] = lambda: shared.get("native", lambda: _appchain_positions(address, proxy)) + if with_nft: + calls["nft"] = lambda: _fetch_nfts(client, address) + r = run_parallel(calls) + dead = next((v for v in r.values() if isinstance(v, ProxyDead)), None) + if dead: + raise dead # прокси мёртв — фолбэки через него бессмысленны + + # Агрегат Rabby — только для контроля и списка сетей. + aggregate_usd: float | None = None + agg: dict = {} + if isinstance(r["agg"], Exception): + if not _aggregate_optional(r["agg"], proxy): + raise r["agg"] + notes.append(f"агрегат Rabby недоступен ({str(r['agg'])[:40]}) — контроль по агрегату пропущен") + else: + agg = r["agg"] or {} + aggregate_usd = float(agg.get("total_usd_value") or 0.0) + agg_chains: dict[str, float] = {} + for c in agg.get("chain_list") or []: + if c.get("id"): + agg_chains[str(c["id"])] = float(c.get("usd_value") or 0.0) # 2) Токены: все сети одним запросом (cache_token_list); при сбое — - # фолбэк на по-сетевой token_list (is_all=false → только core). - # Пустой chain_list (как у расширения) → запросы токенов не нужны. - tokens: list[dict] = [] - if nonzero_chains: - try: - tokens = client.get_cache_token_list(address) - except Exception: - for chain in nonzero_chains: - chain_tokens = client.get_token_list(address, chain_id=chain, is_all=not RABBY_IS_CORE) - tokens.extend(chain_tokens) - tokens = [ - t for t in tokens - if t.get("is_verified", True) and not t.get("is_scam", False) - and (not RABBY_IS_CORE or t.get("is_core", True)) - ] - tokens_usd = sum(t.get("price", 0) * t.get("amount", 0) for t in tokens) - - # 3) DeFi-протоколы (та же структура portfolio_item_list, что у DeBank). - portfolio = client.get_complex_app_list(address) - protocols_data = [] + # по-сетевой token_list по сетям из агрегата (без агрегата — повтор). + if isinstance(r["tokens"], Exception): + nonzero_chains = [c for c, v in agg_chains.items() if v > 0] + if not nonzero_chains and not agg: + raise r["tokens"] + per_chain = run_parallel({c: (lambda c=c: client.get_token_list(address, chain_id=c, + is_all=not RABBY_IS_CORE)) + for c in nonzero_chains}) + tokens = [t for c in nonzero_chains for t in unwrap(per_chain[c])] + else: + tokens = r["tokens"] + tokens = [t for t in tokens if _token_ok(t) and t.get("is_wallet", True)] + portfolio = unwrap(r["apps"]) + native = unwrap(r["native"]) if APPCHAIN_VERIFY else {} + chain_map = r.get("chain_map") or {} + + # 3) Протоколы. EVM-позиции (есть chain у токенов) — пересчёт по asset_token_list. + # App-chain позиции (chain нет) из Rabby НЕ принимаются: их подтверждает + # нативный API (Hyperliquid, Lighter, Polymarket); прочие — в unverified_usd. + protocols_data: list[dict] = [] protocols_usd = 0.0 + proto_by_chain: dict[str, float] = {} + unverified_usd = 0.0 + claimed_appchain: dict[str, float] = {} + unverified_apps: dict[str, float] = {} for proto in portfolio: - items = proto.get("portfolio_item_list", []) - proto_value = sum(_safe_position_value(item) for item in items) + app_id = str(proto.get("id") or "") + items = proto.get("portfolio_item_list") or [] + evm_items = [it for it in items if _item_chain(it)] + app_items = [it for it in items if not _item_chain(it)] + + if app_items: + claimed = sum(max(0.0, float((it.get("stats") or {}).get("net_usd_value") or 0)) for it in app_items) + claimed_appchain[app_id] = claimed_appchain.get(app_id, 0.0) + claimed + if app_id not in APPCHAIN_NATIVE or not APPCHAIN_VERIFY: + unverified_apps[app_id] = unverified_apps.get(app_id, 0.0) + claimed + + if not evm_items: + continue + proto_value = sum(_safe_position_value(item) for item in evm_items) if round(proto_value, 2) < MIN_VALUE_DISPLAY: continue protocols_usd += proto_value positions = [] proto_chain = "" - for item in items: + for item in evm_items: net = _safe_position_value(item) + ch = _item_chain(item) + proto_by_chain[ch] = proto_by_chain.get(ch, 0.0) + net if round(net, 2) < MIN_VALUE_DISPLAY: continue - detail = item.get("detail", {}) - # Rabby не отдаёт chain на уровне приложения; берём из токена позиции, если есть. - for tk in (detail.get("supply_token_list", []) or item.get("asset_token_list", [])): - if tk.get("chain"): - proto_chain = tk["chain"] - break - supply = [ - f"{t.get('symbol', '?')} {t.get('amount', 0):.4f}" - for t in detail.get("supply_token_list", []) - ] - reward = [ - f"{t.get('symbol', '?')} {t.get('amount', 0):.4f}" - for t in detail.get("reward_token_list", []) - ] + detail = item.get("detail") or {} + proto_chain = proto_chain or ch + supply = [f"{t.get('symbol', '?')} {float(t.get('amount') or 0):.4f}" + for t in detail.get("supply_token_list") or []] + reward = [f"{t.get('symbol', '?')} {float(t.get('amount') or 0):.4f}" + for t in detail.get("reward_token_list") or []] positions.append({ "type": item.get("name", ""), "value": round(net, 2), @@ -214,179 +515,447 @@ def _fetch_snapshot(address: str, proxy: str) -> dict[str, Any]: "value": round(proto_value, 2), "positions": positions, }) - protocols_data.sort(key=lambda p: p["value"], reverse=True) - # 4) NFT — все коллекции одним запросом (is_all=true), фильтр скам/непроверенных. - nft_usd = 0.0 - nft_data: list[dict] = [] - nft_chains: set[str] = set() - try: - for col in client.get_collection_list(address, is_all=True): - if col.get("is_scam") or col.get("is_suspicious"): - continue - if RABBY_IS_CORE and col.get("is_verified") is False: - continue - amount = col.get("amount") or len(col.get("nft_list", []) or []) - if not amount: - continue - chain = col.get("chain") or "" - if chain: - nft_chains.add(chain) - nft_data.append({ - "name": col.get("name", "?"), - "chain": chain, - "amount": amount, - }) - except Exception: - pass - nft_data.sort(key=lambda x: (x["chain"], x["name"])) + # 2b) On-chain проверка токенов (фантомы отбрасываются, количества сверяются) + # и — одновременно с ней — свежий token_list по сетям, где агрегат выше + # токенов + EVM-позиций (устаревший кэш или фантом агрегата). Список + # таких сетей угадывается по непроверенным токенам и уточняется после + # проверки; кандидаты из token_list принимаются только после on-chain + # подтверждения. + def fetch_fresh(chain: str) -> Any: + return lambda: client.get_token_list(address, chain_id=chain, is_all=not RABBY_IS_CORE) + + def suspects_for(toks: list[dict]) -> list[str]: + by = _by_chain(toks) + return sorted((c for c, v in agg_chains.items() + if v > COMPONENT_TOL_ABS and v > by.get(c, 0.0) + proto_by_chain.get(c, 0.0) + and not _close(v, by.get(c, 0.0) + proto_by_chain.get(c, 0.0))), + key=lambda c: -agg_chains[c]) + + stage: dict[Any, Any] = {("fresh", c): fetch_fresh(c) for c in suspects_for(tokens)[:CHAIN_REFETCH_MAX]} + raw_tokens = tokens + rabby_tokens_usd = sum(_token_value(t) for t in raw_tokens) + stats: dict[str, Any] = {"rejected_usd": 0.0, "confirmed": set()} + stage["verify"] = lambda: _verify_tokens(raw_tokens, address, chain_map, notes, require=False, + proxy=proxy, memo=shared, stats=stats) + fresh = run_parallel(stage) + tokens = unwrap(fresh.pop("verify")) + + suspects = suspects_for(tokens) + refetch = suspects[:CHAIN_REFETCH_MAX] + missing = [c for c in refetch if ("fresh", c) not in fresh] + fresh.update(run_parallel({("fresh", c): fetch_fresh(c) for c in missing})) + + have = {(str(t.get("chain")), str(t.get("id") or "").lower()) for t in tokens} + candidates: dict[str, list[dict]] = {} + for chain in refetch: + candidates[chain] = [t for t in unwrap(fresh[("fresh", chain)]) + if _token_ok(t) and t.get("is_wallet", True) + and (str(t.get("chain")), str(t.get("id") or "").lower()) not in have] + if candidates[chain] and not ONCHAIN_VERIFY: + candidates[chain] = [] + notes.append(f"{chain}: token_list даёт больше кэша, но без on-chain проверки не принимается") + chain_notes: dict[str, list[str]] = {c: [] for c in candidates} + chain_stats: dict[str, dict[str, Any]] = {c: {} for c in candidates} + confirmed = run_parallel({ + c: (lambda c=c: _verify_tokens(candidates[c], address, chain_map, chain_notes[c], require=True, + proxy=proxy, memo=shared, stats=chain_stats[c])) + for c in refetch if candidates[c] + }) + for st in chain_stats.values(): + stats["confirmed"] |= st.get("confirmed", set()) + by_chain = _by_chain(tokens) + phantom_chains: dict[str, float] = {} + for chain in refetch: + notes.extend(chain_notes.get(chain, [])) + accepted = unwrap(confirmed[chain]) if chain in confirmed else [] + if accepted: + add_usd = sum(_token_value(t) for t in accepted) + tokens = tokens + accepted + by_chain[chain] = by_chain.get(chain, 0.0) + add_usd + notes.append(f"{chain}: кэш токенов устарел, on-chain подтверждены токены на ${add_usd:.2f}") + explained = by_chain.get(chain, 0.0) + proto_by_chain.get(chain, 0.0) + if not _close(agg_chains[chain], explained) and agg_chains[chain] > explained: + phantom_chains[chain] = agg_chains[chain] - explained + for chain in suspects[CHAIN_REFETCH_MAX:]: + phantom_chains[chain] = agg_chains[chain] - by_chain.get(chain, 0.0) - proto_by_chain.get(chain, 0.0) + tokens_usd = sum(_token_value(t) for t in tokens) + + # 3a) App-chain: нативные API — единственный источник значений. + if APPCHAIN_VERIFY: + for key, (label, chain) in APPCHAIN_LABELS.items(): + pos = native.get(key) or {} + value = float(pos.get("total_usd") or 0.0) + if round(value, 2) >= MIN_VALUE_DISPLAY: + protocols_usd += value + protocols_data.append({ + "logo": "", + "name": f"{label} (native API)", + "chain": chain, + "value": round(value, 2), + "positions": [{ + "type": d.get("type", ""), + "value": round(float(d.get("value") or 0), 2), + "supply": f"{d.get('symbol', '?')} {float(d.get('amount') or 0):.4f}", + "rewards": "", + } for d in pos.get("details") or []], + }) + native_total = {k: float((v or {}).get("total_usd") or 0.0) for k, v in native.items()} + for app_id, claimed in claimed_appchain.items(): + nat = APPCHAIN_NATIVE.get(app_id) + if nat and not _close(claimed, native_total.get(nat, 0.0)): + notes.append(f"Rabby приписывает {app_id} ${claimed:.2f}, нативный API даёт " + f"${native_total.get(nat, 0.0):.2f} — данные Rabby отклонены") + raw_evidence.setdefault("rabby_appchain", []).append( + [p for p in portfolio if str(p.get("id") or "") == app_id]) + else: + unverified_apps = dict(claimed_appchain) + for app_id, claimed in unverified_apps.items(): + unverified_usd += claimed + if claimed >= MIN_VALUE_DISPLAY: + notes.append(f"app-chain позиция {app_id} ${claimed:.2f} не проверяется нативным API — в итог не входит") - # Лог сырых данных при аномальном балансе. - _write_log(address, total_usd, tokens_usd, protocols_usd, tokens, portfolio) + protocols_data.sort(key=lambda p: p["value"], reverse=True) + + # 4) NFT (в сумму не входят; сбой не срывает выборку). + nft_data = r["nft"] if with_nft and not isinstance(r["nft"], Exception) else [] - token_chains = {t.get("chain", "") for t in tokens if t.get("chain")} - chains = sorted(set(nonzero_chains) | token_chains | nft_chains) + # 5) Итог и контроль по агрегату. + total_usd = tokens_usd + protocols_usd + aggregate_agrees = True if aggregate_usd is None else _close(aggregate_usd, total_usd) + if not aggregate_agrees: + detail = ", ".join(f"{c}: +${v:.2f}" for c, v in sorted(phantom_chains.items(), key=lambda kv: -kv[1])[:5]) + notes.append(f"агрегат Rabby ${aggregate_usd:.2f} отклонён: проверенные компоненты дают ${total_usd:.2f}" + + (f" (необъяснённые сети: {detail})" if detail else "")) + raw_evidence["aggregate"] = {"total_usd_value": aggregate_usd, + "chain_list": [(c, v) for c, v in agg_chains.items() if v > 0]} + raw_evidence["tokens_by_chain"] = {c: round(v, 4) for c, v in by_chain.items() if v > 0} tokens_data = _build_tokens_data(tokens) top_tokens = sorted(tokens_data, key=lambda t: t["value"], reverse=True)[:3] - top_str = ", ".join(f"{t['symbol']}(${t['value']:.2f})" for t in top_tokens) - return { + snap = { "address": address, "total_usd": total_usd, "tokens_usd": tokens_usd, "protocols_usd": protocols_usd, - "nft_usd": nft_usd, + "nft_usd": 0.0, + "aggregate_usd": aggregate_usd, + "aggregate_agrees": aggregate_agrees, + "unverified_usd": unverified_usd, "tokens": len(tokens_data), - "chains": ", ".join(chains), - "top_tokens": top_str, + "chains": "", + "top_tokens": ", ".join(f"{t['symbol']}(${t['value']:.2f})" for t in top_tokens), "tokens_data": tokens_data, "protocols_data": protocols_data, - "nft_data": nft_data, - "proxy": proxy or "—", + "nft_data": [], + "proxy": proxy or "direct", "status": "OK", "error": "", + "notes": notes, "corroborated": True, + "snapshots": 1, + "onchain_rejected_usd": round(stats["rejected_usd"], 2), + "_rabby_tokens_usd": rabby_tokens_usd, + "_confirmed": frozenset(stats["confirmed"]), } + _attach_nfts(snap, nft_data) + _write_log(address, snap, raw_evidence or None) + return snap + + +def _attach_nfts(snap: dict[str, Any], nft_data: list[dict]) -> None: + """NFT в результат. Список сетей (chains) — только сети с балансом + (токены + протоколы): он не зависит от того, успели ли догрузиться NFT.""" + snap["nft_data"] = nft_data + chains = {t["chain"] for t in snap["tokens_data"] if t.get("chain")} + chains |= {p["chain"] for p in snap["protocols_data"] if p.get("chain")} + snap["chains"] = ", ".join(sorted(chains)) + + +def _wallet_nfts(address: str, proxy_manager: ProxyManager, first_proxy: str | None) -> list[dict]: + """NFT кошелька: запрос через first_proxy; если он упал или не ответил за + NFT_HEDGE_SEC — параллельно следующий через другой прокси (часть запросов + collection_list зависает на стороне сервера). До NFT_ATTEMPTS попыток; + сбой всех → [].""" + def attempt(first: bool) -> Any: + def run() -> list[dict]: + proxy = first_proxy if first else (proxy_manager.get_proxy() or first_proxy) + try: + return _fetch_nfts(RabbyClient(proxy=proxy), address) + except ProxyDead as e: + proxy_manager.report_dead(e.proxy) + raise + return run + + try: + return first_success([attempt(i == 0) for i in range(NFT_ATTEMPTS)], NFT_HEDGE_SEC) + except Exception: # noqa: BLE001 + return [] + + +def _load_nfts_background(result: dict[str, Any], address: str, proxy_manager: ProxyManager, + first_proxy: str | None) -> None: + """Запускает загрузку NFT в фоне; по готовности они дописываются в result.""" + def job() -> None: + _attach_nfts(result, _wallet_nfts(address, proxy_manager, first_proxy)) + + fut = _NFT_POOL.submit(job) + with _NFT_LOCK: + _NFT_PENDING.add(fut) + fut.add_done_callback(_forget_nft) + + +def _forget_nft(fut: Future) -> None: + with _NFT_LOCK: + _NFT_PENDING.discard(fut) + + +def pending_nfts() -> int: + """Сколько кошельков ещё догружают NFT.""" + with _NFT_LOCK: + return len(_NFT_PENDING) + + +def wait_nfts(timeout: float | None = None) -> int: + """Ждёт фоновую загрузку NFT (перед экспортом). Возвращает, сколько не успело.""" + with _NFT_LOCK: + futures = list(_NFT_PENDING) + if futures: + wait(futures, timeout=timeout) + return pending_nfts() + + +# ---------------------------------------------------------------- кошелёк + +def _is_transient(err: Exception) -> bool: + if isinstance(err, OnchainCheckFailed): + return True + s = str(err).lower() + return any(k in s for k in ("timeout", "timed out", "429", "connection", "reset", "502", "503", "504", "proxy", + "недоступ")) + + +def _is_rate_limited(err: Exception) -> bool: + s = str(err).lower() + return "429" in s or "403" in s or "too many" in s or "forbidden" in s + + +def _retry_delay(err: Exception, failures: int, proxy: str | None) -> float: + """Пауза перед повтором выборки. Через прокси следующий запрос уходит с + другого IP — ждать почти не нужно; в прямом режиме — нарастающая пауза.""" + if proxy is not None: + return RETRY_PROXY_BACKOFF_SEC + if _is_rate_limited(err): + return min(RETRY_429_BACKOFF_SEC * failures, 30.0) + if _is_transient(err): + return min(RETRY_BACKOFF_SEC * failures, 10.0) + return 0.0 + + +def _delayed_snapshot(address: str, proxy: str | None, shared: Memo, delay: float) -> dict[str, Any]: + if delay > 0: + time.sleep(delay) + return _fetch_snapshot(address, proxy, shared, with_nft=False) def check_wallet(address: str, proxy_manager: ProxyManager, wallet_idx: int = -1) -> dict[str, Any]: """Проверяет баланс одного кошелька с защитой от «фантомных» балансов. - Сетевые ошибки → повтор с новым прокси. При CORROBORATION_ENABLED баланс - принимается только если подтверждён CORROBORATION_MIN_AGREE независимыми - выборками, сошедшимися по total_usd; иначе берётся консервативное значение - (наибольшая согласованная группа, при равенстве — меньшая сумма). + CORROBORATION_MIN_AGREE выборок запускаются одновременно через разные + прокси; упавшая выборка сразу заменяется новой (другой прокси), а если + выборка не завершилась за SNAPSHOT_HEDGE_SEC, параллельно запускается + замена (хедж) — берутся первые согласованные. NFT догружаются в фоне и + дописываются в результат позже — перед экспортом вызвать wait_nfts(). + Баланс принимается (OK), когда + набралось CORROBORATION_MIN_AGREE согласованных по total_usd выборок. Если + бюджет выборок исчерпан без согласия — UNVERIFIED с консервативным + значением (наибольшая согласованная группа, при равенстве — меньшая сумма). """ - last_error = None - used_proxy = None total_start = time.perf_counter() + need = max(1, CORROBORATION_MIN_AGREE) if CORROBORATION_ENABLED else 1 + max_fetches = max(1, CORROBORATION_MAX_FETCHES) if CORROBORATION_ENABLED else 1 + max_attempts = max(RETRY_ATTEMPTS, max_fetches * 3) if CORROBORATION_ENABLED else RETRY_ATTEMPTS snapshots: list[dict[str, Any]] = [] - fetches = 0 - attempts = 0 - # Запас попыток на сетевые сбои поверх бюджета успешных выборок. - max_attempts = (max(RETRY_ATTEMPTS, CORROBORATION_MAX_FETCHES * 3) - if CORROBORATION_ENABLED else RETRY_ATTEMPTS) - - while attempts < max_attempts: - attempts += 1 - if CORROBORATION_ENABLED and fetches >= CORROBORATION_MAX_FETCHES: - break - - attempt_start = time.perf_counter() + pending: dict[Future, str | None] = {} + started: dict[Future, float] = {} + attempts = failures = 0 + last_error: Exception | None = None + used_proxy: str | None = None + # Кэш «поколения» выборок: нативные API и on-chain считаются один раз на + # поколение. Хеджи зависших выборок начинают новое поколение, чтобы не + # ждать тот же зависший запрос. + memo = Memo() + pool = ThreadPoolExecutor(max_workers=max_fetches) + + def budget_left() -> bool: + return len(snapshots) + len(pending) < max_fetches and attempts < max_attempts + + def launch(delay: float = 0.0) -> bool: + nonlocal attempts, used_proxy proxy = proxy_manager.get_proxy() - if not proxy: - if not snapshots: - if DEBUG: - _debug_log(wallet_idx, address, "NO_PROXY", attempts, 0, "—") - return _error_result(address, "Нет доступных прокси", used_proxy) - break + if not proxy and not direct_allowed(): + return False + attempts += 1 used_proxy = proxy + fut = pool.submit(_delayed_snapshot, address, proxy, memo, delay) + pending[fut] = proxy + started[fut] = time.monotonic() + delay + return True + + def active() -> list[Future]: + """Выборки в полёте, которые ещё не считаются зависшими.""" + now = time.monotonic() + return [f for f in pending if now - started[f] < SNAPSHOT_HEDGE_SEC] + + def top_up(delay: float = 0.0) -> None: + """Держит в полёте столько живых выборок, сколько не хватает до согласия.""" + want = max(1, need - len(_largest_agreeing_cluster(_eligible(snapshots)))) + while len(active()) < want and budget_left() and launch(delay): + pass - try: - snap = _fetch_snapshot(address, proxy) - except Exception as e: - last_error = e - err_str = str(e).lower() - if "timeout" in err_str or "timed out" in err_str: - proxy_manager.report_timeout(used_proxy) - if DEBUG: - _debug_log(wallet_idx, address, "FAIL", attempts, - time.perf_counter() - attempt_start, - _mask_proxy(used_proxy), str(e)[:80]) - continue - - fetches += 1 - - if not CORROBORATION_ENABLED: - if DEBUG: - _debug_log(wallet_idx, address, "OK", attempts, - time.perf_counter() - total_start, _mask_proxy(used_proxy)) - return snap - - # Корроборация: ищем группу согласованных по total_usd выборок. - snapshots.append(snap) - cluster = [s for s in snapshots if _agree(s["total_usd"], snap["total_usd"])] - if len(cluster) >= CORROBORATION_MIN_AGREE: - chosen = _rep(cluster) - chosen["corroborated"] = True - if DEBUG: - _debug_log(wallet_idx, address, "OK", attempts, - time.perf_counter() - total_start, _mask_proxy(used_proxy)) - return chosen - - # Бюджет исчерпан без подтверждения — берём консервативный результат. - if snapshots: - chosen = _rep(_largest_agreeing_cluster(snapshots)) - chosen["corroborated"] = False - chosen["error"] = (chosen.get("error") - or "баланс не подтверждён (возможен фантом) — взято консервативное значение") - if DEBUG: - others = sorted(round(s["total_usd"], 2) for s in snapshots) - _debug_log(wallet_idx, address, "WARN", attempts, - time.perf_counter() - total_start, _mask_proxy(used_proxy), - f"не подтверждён, выборки={others}") - return chosen - - if DEBUG: - elapsed = time.perf_counter() - total_start - _debug_log(wallet_idx, address, "ERROR", attempts, elapsed, - _mask_proxy(used_proxy), str(last_error)[:80]) - - return _error_result( - address, - str(last_error) if last_error else "Unknown error", - used_proxy, - ) - - -def _debug_log(idx: int, addr: str, status: str, attempt: int, sec: float, proxy: str, err: str = "") -> None: - """Отладочный лог в stderr.""" - if not DEBUG: - return - import sys - short = f"{addr[:10]}...{addr[-6:]}" if len(addr) > 20 else addr - err_part = f" | {err}" if err else "" - line = f"[DEBUG] #{idx} {short} | {status} | попытка {attempt} | {sec:.2f}s | {proxy}{err_part}\n" - sys.stderr.write(line) - sys.stderr.flush() - - -def _error_result(address: str, err_msg: str, proxy: str | None) -> dict[str, Any]: - return { - "address": address, - "total_usd": 0.0, - "tokens_usd": 0.0, - "protocols_usd": 0.0, - "nft_usd": 0.0, - "tokens": 0, - "chains": "", - "top_tokens": "", - "tokens_data": [], - "protocols_data": [], - "nft_data": [], - "proxy": proxy or "—", - "status": "ERROR", - "error": err_msg, - } \ No newline at end of file + try: + top_up() + if not pending: + _debug_log(wallet_idx, address, "NO_PROXY", attempts, 0, "—") + return _error_result(address, "Нет доступных прокси", used_proxy) + first_proxy = used_proxy + + result: dict[str, Any] | None = None + while pending: + now = time.monotonic() + live = active() + timeout = (max(0.05, min(started[f] + SNAPSHOT_HEDGE_SEC - now for f in live)) + if live and budget_left() else None) + done, _ = wait(list(pending), timeout=timeout, return_when=FIRST_COMPLETED) + if not done: + memo = Memo() # хедж: новое поколение, мимо зависших запросов + top_up() + continue + retry_after = 0.0 + for fut in done: + proxy = pending.pop(fut) + started.pop(fut, None) + try: + snapshots.append(fut.result()) + except Exception as e: # noqa: BLE001 + last_error = e + failures += 1 + err_str = str(e).lower() + if isinstance(e, ProxyDead): + proxy_manager.report_dead(e.proxy) + elif proxy and ("timeout" in err_str or "timed out" in err_str): + proxy_manager.report_timeout(proxy) + elif proxy and _is_rate_limited(e): + proxy_manager.report_rate_limited(proxy) + retry_after = _retry_delay(e, failures, proxy) + _debug_log(wallet_idx, address, "FAIL", attempts, time.perf_counter() - total_start, + _mask_proxy(proxy), str(e)[:80]) + + cluster = _largest_agreeing_cluster(_eligible(snapshots)) + if len(cluster) >= need: + result = _finalize(_rep(cluster), snapshots, cluster, corroborated=True) + break + top_up(retry_after) + + if result is None and snapshots: + cluster = _largest_agreeing_cluster(_eligible(snapshots) or snapshots) + result = _finalize(_rep(cluster), snapshots, cluster, corroborated=False) + if result is None: + _debug_log(wallet_idx, address, "ERROR", attempts, time.perf_counter() - total_start, + _mask_proxy(used_proxy), str(last_error)[:80]) + return _error_result(address, str(last_error) if last_error else "Unknown error", used_proxy) + + _load_nfts_background(result, address, proxy_manager, first_proxy) + _debug_log(wallet_idx, address, result["status"], attempts, time.perf_counter() - total_start, + _mask_proxy(used_proxy)) + return result + finally: + pool.shutdown(wait=False, cancel_futures=True) + + +def _is_tainted(snap: dict[str, Any]) -> bool: + """On-chain проверка сняла заметную часть токенов → Rabby отдал для этого + запроса чужой список (подмена целиком: наших токенов в нём может не быть).""" + rejected = snap.get("onchain_rejected_usd", 0.0) + return rejected > max(TAINT_ABS_USD, TAINT_REL * snap.get("_rabby_tokens_usd", 0.0)) + + +def _eligible(snapshots: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Выборки, которые могут участвовать в согласии: не заражённые и полные — + содержат все токены, подтверждённые на цепочке в любой выборке кошелька. + On-chain проверка снимает лишние токены, но пропавший из ответа Rabby + токен можно заметить только по другим выборкам.""" + known: set = set() + for s in snapshots: + known |= s.get("_confirmed", frozenset()) + return [s for s in snapshots + if not _is_tainted(s) and known <= s.get("_confirmed", frozenset())] + + +def _finalize(chosen: dict[str, Any], snapshots: list[dict[str, Any]], + cluster: list[dict[str, Any]], corroborated: bool) -> dict[str, Any]: + """Сводит примечания всех выборок в выбранный результат и ставит статус.""" + notes: list[str] = [] + for s in snapshots: + for n in s.get("notes", []): + if n not in notes: + notes.append(n) + rejected = sum(1 for s in snapshots if not s.get("aggregate_agrees", True)) + if rejected: + notes.insert(0, f"агрегат Rabby отклонён в {rejected} из {len(snapshots)} выборок") + chosen = {k: v for k, v in chosen.items() if not k.startswith("_")} + eligible_ids = {id(s) for s in _eligible(snapshots)} + skipped = sum(1 for s in snapshots if id(s) not in eligible_ids) + if skipped: + notes.insert(0, f"отброшено выборок с чужим/неполным списком токенов Rabby: {skipped}") + chosen["notes"] = notes + chosen["snapshots"] = len(snapshots) + chosen["corroborated"] = corroborated + if corroborated: + chosen["status"] = "OK" + chosen["error"] = "; ".join(notes) + else: + values = sorted(round(s["total_usd"], 2) for s in snapshots) + chosen["status"] = "UNVERIFIED" + chosen["error"] = (f"баланс не подтверждён: выборки {values}, " + f"взято консервативное ${chosen['total_usd']:.2f}" + + ("; " + "; ".join(notes) if notes else "")) + return chosen + + +def _debug_log(idx: int, addr: str, status: str, attempt: int, sec: float, proxy: str, err: str = "") -> None: + """Отладочный лог в stderr (config.DEBUG).""" + if not DEBUG: + return + short = f"{addr[:10]}...{addr[-6:]}" if len(addr) > 20 else addr + err_part = f" | {err}" if err else "" + sys.stderr.write(f"[DEBUG] #{idx} {short} | {status} | попытка {attempt} | {sec:.2f}s | {proxy}{err_part}\n") + sys.stderr.flush() + + +def _error_result(address: str, err_msg: str, proxy: str | None) -> dict[str, Any]: + return { + "address": address, + "total_usd": 0.0, + "tokens_usd": 0.0, + "protocols_usd": 0.0, + "nft_usd": 0.0, + "aggregate_usd": 0.0, + "aggregate_agrees": True, + "unverified_usd": 0.0, + "tokens": 0, + "chains": "", + "top_tokens": "", + "tokens_data": [], + "protocols_data": [], + "nft_data": [], + "proxy": proxy or "direct", + "status": "ERROR", + "error": err_msg, + "notes": [], + "corroborated": False, + "snapshots": 0, + "onchain_rejected_usd": 0.0, + } diff --git a/debank_checker/config.py b/debank_checker/config.py index 4aa78d8..d58691f 100644 --- a/debank_checker/config.py +++ b/debank_checker/config.py @@ -1,14 +1,17 @@ """ Конфигурация DeBank Balance Checker """ +from __future__ import annotations + +import os # Пути к файлам WALLETS_FILE = "wallets.txt" PROXIES_FILE = "proxy.txt" -OUTPUT_DIR = "Results" # папка для Excel-результатов +OUTPUT_DIR = "Results" # папка для результатов экспорта # API -REQUEST_TIMEOUT = 3 # секунд (быстрый failover при мёртвых прокси, см. docs/DEBUG_REPORT.md) +REQUEST_TIMEOUT = 8 # секунд на запрос к Rabby (3 с было мало для complex_app_list) # --- Rabby API ----------------------------------------------------------- # Init-ключ ротируется сервером через x-set-api-key; ротированный ключ хранится @@ -27,31 +30,110 @@ # Прокси и rate limit RATE_LIMIT_REQ_PER_MIN = 60 # запросов в минуту на один прокси -RETRY_ATTEMPTS = 10 # попыток при ошибке (с новым прокси, без задержки между попытками) +RETRY_ATTEMPTS = 10 # минимум попыток выборки на кошелёк (каждая — с новым прокси) +RETRY_BACKOFF_SEC = 1.0 # прямой режим: пауза после сетевой ошибки (× номер неудачи, до 10 с) +RETRY_429_BACKOFF_SEC = 4.0 # прямой режим: пауза после 429 (× номер неудачи, до 30 с) +RETRY_PROXY_BACKOFF_SEC = 0.3 # через прокси: пауза перед повтором — следующий идёт с другого IP +PROXY_COOLDOWN_429_SEC = 15 # прокси, получивший 429/403 от Rabby, не выдаётся столько секунд +RABBY_RETRY_429 = 3 # прямой режим: повторов в клиенте Rabby на 429/5xx (3·6·12 с или Retry-After) +# Работа без прокси. По умолчанию запрещена (прокси обязателен); включается +# переменной окружения DBC_DIRECT=1 или флагом --direct у audit_passes.py. +ALLOW_DIRECT = os.environ.get("DBC_DIRECT", "") in ("1", "true", "yes") +# Глобальный лимит запросов к Rabby в прямом режиме (один IP): ~10 req/s с +# одного адреса приводят к HTTP 403 на /v1/user/total_balance. +RABBY_DIRECT_RATE_PER_SEC = 4.0 +# Агрегат total_balance нужен только для контроля; при 403 (в прямом режиме и +# при 429) выборка продолжается без него (aggregate_usd=None). Через прокси +# 429 → выборка повторяется с другого IP, чтобы не терять сверку по сетям. +AGGREGATE_OPTIONAL = True # Параллелизм -MAX_WORKERS = 500 # максимум одновременных воркеров -PROXY_MULTIPLIER = 5 # max_workers = min(MAX_WORKERS, len(wallets), len(proxies) * PROXY_MULTIPLIER) +MAX_WORKERS = 500 # максимум одновременно проверяемых кошельков +PROXY_MULTIPLIER = 5 # воркеров = min(MAX_WORKERS, кошельков, прокси × PROXY_MULTIPLIER) +DIRECT_WORKERS = 8 # воркеров в прямом режиме (один IP) +# Выборки одного кошелька (CORROBORATION_MIN_AGREE) идут одновременно через +# разные прокси; выборка, не завершившаяся за SNAPSHOT_HEDGE_SEC, дублируется +# новой («хедж») — берутся первые согласованные. +SNAPSHOT_HEDGE_SEC = 5.0 # Минимальная сумма для отображения (USD) MIN_VALUE_DISPLAY = 0.01 # --- Защита от «фантомных» балансов ------------------------------------- -# Баланс принимается, только если подтверждён CORROBORATION_MIN_AGREE -# независимыми выборками, сошедшимися в пределах допуска. Для Rabby итог -# авторитетен (total_usd_value из /v1/user/total_balance), поэтому по -# умолчанию MIN_AGREE=1 — корроборация не тратит лишние запросы, но механика -# остаётся как страховка. +# С 11.09.2026 бэкенд Rabby отдаёт для части адресов ЧУЖИЕ данные: +# * /v1/user/total_balance — агрегат с суммами чужого кошелька (одинаковые +# значения у разных адресов), «липнет» на минуты-часы; +# * /v1/user/complex_app_list — случайные app-chain позиции (Hyperliquid, +# Lighter, Polymarket…) с нашим user_addr, но чужими суммами; от запроса к +# запросу меняются ([] → $18k → $6k → []). +# Список токенов (/v1/user/cache_token_list) при этом стабилен и совпадает с +# независимыми источниками. Поэтому: +# 1. Итог НИКОГДА не берётся из total_usd_value. Итог = токены кошелька +# + EVM-позиции протоколов (пересчитанные по asset_token_list) +# + app-chain позиции, подтверждённые нативными API (Hyperliquid, Lighter, Polymarket). +# 2. Агрегат Rabby используется только как контроль: расхождение с +# проверенными компонентами фиксируется в примечании. +# 3. App-chain позиции из Rabby, которые нельзя проверить нативным API, +# в итог не входят (учитываются отдельно как unverified_usd). +# 4. Баланс принимается, только если CORROBORATION_MIN_AGREE независимых +# выборок сошлись по итогу. Иначе статус UNVERIFIED. +COMPONENT_TOL_ABS = 1.0 # абсолютный допуск сверки компонент/агрегата (USD) +COMPONENT_TOL_REL = 0.02 # относительный допуск (2%) +CHAIN_REFETCH_MAX = 6 # сколько сетей перепроверять свежим token_list, если агрегат по сети выше токенов + +# Нативные API для app-chain позиций +APPCHAIN_VERIFY = True # False — app-chain позиции полностью игнорируются +HL_API_URL = "https://api.hyperliquid.xyz/info" +HL_RATE_PER_SEC = 9.0 # лимит запросов к Hyperliquid НА ОДИН IP (weight 2 → 1080/мин < 1200) +# Нативные API и RPC ходят через тот же прокси, что и Rabby (если прокси есть): +# лимиты публичных сервисов делятся на все IP, а не упираются в один. +APPCHAIN_VIA_PROXY = True +LIGHTER_API_URL = "https://mainnet.zklighter.elliot.ai/api/v1/account" +APPCHAIN_RATE_PER_SEC = 4.0 # лимит запросов к Polymarket data-api / Lighter на один IP +APPCHAIN_TIMEOUT = 8 + +# On-chain верификация токенов через публичные JSON-RPC (api/onchain.py): +# токены с оценкой ≥ ONCHAIN_MIN_USD подтверждаются eth_getBalance/balanceOf. +# Фантомный токен (on-chain 0) отбрасывается, расхождение количества +# исправляется по данным сети. Кандидаты из по-сетевого token_list +# принимаются ТОЛЬКО после on-chain подтверждения. +ONCHAIN_VERIFY = True +ONCHAIN_MIN_USD = 0.5 +ONCHAIN_RPC_TIMEOUT = 6 +ONCHAIN_RPC_HEDGE_SEC = 1.5 # нода молчит дольше — параллельно спрашиваем следующую +ONCHAIN_ROUNDS = 3 # раундов перебора всех RPC сети до признания сети недоступной +ONCHAIN_AMOUNT_TOL = 0.01 # допуск расхождения количества (1%) +# Токен ≥ ONCHAIN_MIN_USD без on-chain подтверждения → выборка не засчитывается +# (повтор, затем UNVERIFIED). Ничего непроверенного в итог не попадает. + +# Rabby иногда отдаёт для кошелька ЧУЖОЙ список токенов целиком (наших в нём +# нет). On-chain проверка снимает чужие токены, но пропажу наших не видит. +# Поэтому выборка не участвует в согласии, если: +# * on-chain проверка сняла > max(TAINT_ABS_USD, TAINT_REL × стоимость +# токенов по Rabby) — «заражённая» выборка; +# * в ней нет токена, подтверждённого на цепочке в другой выборке — «неполная». +TAINT_ABS_USD = 5.0 +TAINT_REL = 0.10 + CORROBORATION_ENABLED = True # False — принять первую же выборку -CORROBORATION_MIN_AGREE = 1 # сошедшихся выборок нужно для приёма -CORROBORATION_MAX_FETCHES = 8 # бюджет успешных выборок на кошелёк +CORROBORATION_MIN_AGREE = 2 # сошедшихся выборок нужно для приёма +CORROBORATION_MAX_FETCHES = 6 # бюджет выборок на кошелёк (включая хеджи) CORROBORATION_REL_TOL = 0.02 # относительный допуск согласия (2%) CORROBORATION_ABS_TOL = 1.0 # абсолютный допуск согласия (USD) -# NFT -NFT_POLL_INTERVAL = 3 # сек между попытками при async job -NFT_POLL_MAX_WAIT = 30 # макс время ожидания результата -NFT_REQUEST_TIMEOUT = 3 # таймаут запроса (как REQUEST_TIMEOUT) +# NFT (в сумму не входят). Для кошельков с сотнями коллекций сервер считает +# collection_list 6–20 с, поэтому NFT грузятся в фоне и НЕ задерживают баланс: +# экспорт ждёт их догрузки (checker.wait_nfts). Запрос, не ответивший за +# NFT_HEDGE_SEC, дублируется через другой прокси (до NFT_ATTEMPTS попыток). +NFT_REQUEST_TIMEOUT = 25 +NFT_HEDGE_SEC = 12.0 +NFT_ATTEMPTS = 3 +NFT_BACKGROUND_WORKERS = 256 + +# Лог сырых данных (logs.txt): писать сырые ответы при отклонённом агрегате / +# фантомных app-chain позициях. Ограничение размера одной записи в байтах. +RAW_LOG_ENABLED = True +RAW_LOG_MAX_BYTES = 20_000 # Отладка DEBUG = False # True — отладочное логирование (время, попытки, прокси) diff --git a/debank_checker/data/rpc_registry.json b/debank_checker/data/rpc_registry.json new file mode 100644 index 0000000..9059f0f --- /dev/null +++ b/debank_checker/data/rpc_registry.json @@ -0,0 +1,441 @@ +{ + "59144": [ + "https://linea-rpc.publicnode.com", + "https://rpc.linea.build", + "https://linea.drpc.org", + "https://linea.gateway.tenderly.co", + "https://1rpc.io/linea" + ], + "34443": [ + "https://mainnet.mode.network", + "https://1rpc.io/mode" + ], + "988": [ + "https://stable.gateway.tenderly.co", + "https://rpc.stable.xyz", + "https://stable.drpc.org" + ], + "1111": [ + "https://wemix.drpc.org", + "https://api.wemix.com" + ], + "252": [ + "https://fraxtal-rpc.publicnode.com", + "https://rpc.frax.com", + "https://fraxtal.gateway.tenderly.co", + "https://fraxtal.drpc.org" + ], + "48900": [ + "https://mainnet.zircuit.com" + ], + "50": [ + "https://rpc.ankr.com/xdc", + "https://rpc.xdcrpc.com", + "https://erpc.xdcrpc.com" + ], + "33139": [ + "https://rpc.apechain.com", + "https://apechain.gateway.tenderly.co", + "https://apechain.calderachain.xyz/http", + "https://apechain.drpc.org" + ], + "1135": [ + "https://lisk.gateway.tenderly.co", + "https://lisk.drpc.org", + "https://rpc.api.lisk.com" + ], + "57073": [ + "https://ink-rpc.publicnode.com", + "https://ink.drpc.org", + "https://ink.gateway.tenderly.co", + "https://rpc-qnd.inkonchain.com", + "https://rpc-gel.inkonchain.com" + ], + "1088": [ + "https://metis-rpc.publicnode.com", + "https://metis.drpc.org", + "https://andromeda.metis.io/?owner=1088", + "https://metis.api.onfinality.io/public" + ], + "2818": [ + "https://rpc-quicknode.morphl2.io", + "https://morph.drpc.org", + "https://rpc.morphl2.io" + ], + "80094": [ + "https://berachain.drpc.org", + "https://berachain-rpc.publicnode.com", + "https://rpc.berachain-apis.com", + "https://rpc.berachain.com", + "https://berachain.gateway.tenderly.co" + ], + "1329": [ + "https://evm-rpc.sei-apis.com" + ], + "1": [ + "https://eth-mainnet.public.blastapi.io", + "https://mainnet.gateway.tenderly.co", + "https://ethereum-rpc.publicnode.com", + "https://eth.drpc.org", + "https://rpc.mevblocker.io/fullprivacy", + "https://rpc.mevblocker.io/noreverts", + "https://ethereum.drpc.org", + "https://rpc.flashbots.net/fast", + "https://rpc.flashbots.net", + "https://eth.meowrpc.com", + "https://rpc.mevblocker.io/fast", + "https://rpc.mevblocker.io", + "https://xrpc.cl/eth", + "https://1rpc.io/eth" + ], + "42161": [ + "https://arbitrum-one-rpc.publicnode.com", + "https://arbitrum.gateway.tenderly.co", + "https://arb1.arbitrum.io/rpc", + "https://arbitrum-rpc.publicnode.com", + "https://arbitrum.drpc.org", + "https://xrpc.cl/arbitrum", + "https://arb.drpc.org", + "https://1rpc.io/arb" + ], + "1116": [ + "https://rpc.ankr.com/core", + "https://rpc.coredao.org", + "https://core.drpc.org", + "https://rpc.coredao.org/" + ], + "1625": [ + "https://rpc.gravity.xyz" + ], + "100": [ + "https://gnosis.gateway.tenderly.co", + "https://gnosis.oat.farm", + "https://xdai.drpc.org", + "https://gnosis.drpc.org", + "https://rpc.gnosis.gateway.fm", + "https://gnosis-rpc.publicnode.com", + "https://rpc.gnosischain.com" + ], + "43111": [ + "https://hemi.drpc.org", + "https://rpc.hemi.network/rpc" + ], + "1030": [ + "https://evm.confluxrpc.com" + ], + "7000": [ + "https://zetachain-evm.blockpi.network/v1/rpc/public", + "https://zeta-chain.drpc.org", + "https://zetachain-mainnet.g.allthatnode.com/archive/evm" + ], + "98866": [ + "https://rpc.plume.org", + "https://plume.drpc.org" + ], + "4326": [ + "https://mainnet.megaeth.com/rpc", + "https://megaeth.gateway.tenderly.co", + "https://megaeth.drpc.org", + "https://1rpc.io/megaeth" + ], + "4217": [ + "https://rpc.tempo.xyz", + "https://tempo.gateway.tenderly.co", + "https://tempo-rpc.publicnode.com", + "https://rpc.mainnet.tempo.xyz", + "https://tempo.drpc.org", + "https://1rpc.io/tempo" + ], + "7560": [ + "https://rpc.cyber.co/", + "https://rpc.cyber.co", + "https://cyber.alt.technology", + "https://cyber.alt.technology/" + ], + "143": [ + "https://rpc.monad.xyz", + "https://monad.gateway.tenderly.co", + "https://monad.drpc.org" + ], + "223": [ + "https://b2.drpc.org", + "https://b2-mainnet.alt.technology", + "https://bsquared.drpc.org", + "https://rpc.bsquared.network" + ], + "20240603": [ + "https://rpc.mainnet.dbkchain.io" + ], + "2020": [ + "https://api.roninchain.com/rpc", + "https://ronin.gateway.tenderly.co" + ], + "204": [ + "https://opbnb-mainnet-rpc.bnbchain.org", + "https://opbnb-mainnet.nodereal.io/v1/e9a36765eb8a40b9bd12e680a1fd2bc5", + "https://opbnb-mainnet.nodereal.io/v1/64a9df0874fb4a93b9d0a3849de012d3", + "https://opbnb-rpc.publicnode.com", + "https://opbnb.drpc.org", + "https://1rpc.io/opbnb" + ], + "13371": [ + "https://immutable.gateway.tenderly.co", + "https://rpc.immutable.com", + "https://immutable-zkevm.drpc.org" + ], + "130": [ + "https://unichain.gateway.tenderly.co", + "https://unichain.drpc.org", + "https://mainnet.unichain.org", + "https://unichain-rpc.publicnode.com", + "https://1rpc.io/unichain" + ], + "122": [ + "https://fuse.drpc.org", + "https://rpc.fuse.io" + ], + "4200": [ + "https://merlin.drpc.org", + "https://rpc.merlinchain.io" + ], + "30": [ + "https://mycrypto.rsk.co", + "https://public-node.rsk.co", + "https://rootstock.drpc.org" + ], + "2366": [ + "https://kite.drpc.org", + "https://rpc.gokite.ai" + ], + "5042": [ + "https://arc.gateway.tenderly.co", + "https://arc-rpc.publicnode.com", + "https://arc.drpc.org", + "https://rpc.quicknode.mainnet.arc.io", + "https://rpc.mainnet.arc.io", + "https://rpc.drpc.mainnet.arc.io", + "https://rpc.beamrpc.com", + "https://rpc.blockdaemon.mainnet.arc.io" + ], + "56": [ + "https://bsc-dataseed1.bnbchain.org", + "https://bsc-dataseed4.ninicoin.io", + "https://bsc-dataseed1.defibit.io", + "https://bsc-dataseed2.bnbchain.org", + "https://bsc-mainnet.public.blastapi.io", + "https://bsc-dataseed3.ninicoin.io", + "https://bsc-rpc.publicnode.com", + "https://bsc-dataseed3.defibit.io", + "https://bsc-dataseed2.defibit.io", + "https://bsc-dataseed3.bnbchain.org", + "https://bsc-dataseed4.defibit.io", + "https://bsc-dataseed2.ninicoin.io", + "https://bsc-dataseed1.ninicoin.io", + "https://bsc-dataseed4.bnbchain.org", + "https://bsc.meowrpc.com", + "https://xrpc.cl/bsc", + "https://1rpc.io/bnb" + ], + "50104": [ + "https://rpc.sophon.xyz" + ], + "10": [ + "https://1rpc.io/op", + "https://optimism-rpc.publicnode.com", + "https://optimism.drpc.org", + "https://optimism.gateway.tenderly.co", + "https://mainnet.optimism.io", + "https://xrpc.cl/optimism" + ], + "2222": [ + "https://rpc.ankr.com/kava_evm", + "https://kava-evm-rpc.publicnode.com", + "https://evm.kava.io", + "https://kava.drpc.org", + "https://evm.kava-rpc.com" + ], + "146": [ + "https://sonic-rpc.publicnode.com", + "https://sonic.drpc.org", + "https://sonic.gateway.tenderly.co", + "https://rpc.soniclabs.com", + "https://1rpc.io/sonic" + ], + "200901": [ + "https://bitlayer.drpc.org", + "https://rpc.bitlayer.org" + ], + "167000": [ + "https://rpc.ankr.com/taiko", + "https://rpc.mainnet.taiko.xyz", + "https://taiko-rpc.publicnode.com", + "https://taiko.drpc.org" + ], + "4663": [ + "https://rpc.mainnet.chain.robinhood.com", + "https://robinhood-rpc.publicnode.com", + "https://robinhood.drpc.org", + "https://rpc.ordofi.network" + ], + "43114": [ + "https://avalanche-c-chain-rpc.publicnode.com", + "https://avalanche.drpc.org", + "https://avalanche-rpc.publicnode.com", + "https://api.avax.network/ext/bc/C/rpc", + "https://xrpc.cl/avalanche", + "https://avalanche.gateway.tenderly.co" + ], + "4114": [ + "https://rpc.mainnet.citrea.xyz" + ], + "60808": [ + "https://bob.gateway.tenderly.co", + "https://bob.drpc.org", + "https://rpc.gobob.xyz" + ], + "25": [ + "https://cronos.drpc.org", + "https://cronos-evm-rpc.publicnode.com", + "https://evm.cronos.org" + ], + "480": [ + "https://worldchain-mainnet.gateway.tenderly.co", + "https://worldchain-mainnet.g.alchemy.com/public", + "https://worldchain.drpc.org" + ], + "137": [ + "https://rpc-mainnet.matic.quiknode.pro", + "https://polygon.gateway.tenderly.co", + "https://xrpc.cl/polygon", + "https://matic.drpc.org", + "https://polygon.drpc.org", + "https://polygon-bor-rpc.publicnode.com", + "https://1rpc.io/matic" + ], + "747474": [ + "https://katana.gateway.tenderly.co", + "https://rpc.katanarpc.com/", + "https://katana.gateway.tenderly.co/", + "https://rpc.katanarpc.com", + "https://rpc.katana.network", + "https://katana.drpc.org" + ], + "999": [ + "https://hyperliquid-rpc.publicnode.com", + "https://rpc.hyperliquid.xyz/evm", + "https://hyperliquid.drpc.org", + "https://rpc.hyperlend.finance", + "https://1rpc.io/hyperliquid" + ], + "169": [ + "https://pacific-rpc.manta.network/http", + "https://1rpc.io/manta", + "https://manta-pacific.drpc.org" + ], + "7777777": [ + "https://rpc.zora.energy/", + "https://zora.drpc.org", + "https://rpc.zora.energy" + ], + "9745": [ + "https://rpc.plasma.to", + "https://plasma.gateway.tenderly.co" + ], + "534352": [ + "https://scroll-rpc.publicnode.com", + "https://scroll.drpc.org", + "https://rpc.scroll.io", + "https://1rpc.io/scroll" + ], + "42220": [ + "https://rpc.ankr.com/celo", + "https://celo-rpc.publicnode.com", + "https://celo.gateway.tenderly.co", + "https://1rpc.io/celo", + "https://forno.celo.org", + "https://celo.drpc.org" + ], + "232": [ + "https://lens.gateway.tenderly.co", + "https://rpc.lens.xyz", + "https://lens.drpc.org" + ], + "1514": [ + "https://mainnet.storyrpc.io", + "https://mainnet.datarpc.io", + "https://story-rpc.publicnode.com" + ], + "324": [ + "https://zksync.api.onfinality.io/public", + "https://mainnet.era.zksync.io", + "https://zksync.gateway.tenderly.co", + "https://zksync.drpc.org", + "https://zksync-era.drpc.org" + ], + "88888": [ + "https://rpc.ankr.com/chiliz", + "https://chiliz-rpc.publicnode.com", + "https://chiliz.publicnode.com", + "https://rpc.chiliz.com" + ], + "2741": [ + "https://abstract.drpc.org", + "https://api.mainnet.abs.xyz" + ], + "16661": [ + "https://0g-rpc.publicnode.com", + "https://0g.drpc.org" + ], + "8453": [ + "https://xrpc.cl/base", + "https://base-mainnet.public.blastapi.io", + "https://base.meowrpc.com", + "https://rpc.baseazul.dev", + "https://mainnet.base.org/", + "https://base-rpc.publicnode.com", + "https://developer-access-mainnet.base.org/", + "https://base.drpc.org", + "https://mainnet.base.org", + "https://base.gateway.tenderly.co", + "https://1rpc.io/base" + ], + "8217": [ + "https://rpc.ankr.com/kaia", + "https://rpc.ankr.com/klaytn", + "https://public-en.node.kaia.io" + ], + "5000": [ + "https://mantle-rpc.publicnode.com", + "https://mantle.gateway.tenderly.co", + "https://mantle.drpc.org", + "https://rpc.mantle.xyz" + ], + "42793": [ + "https://node.mainnet.etherlink.com", + "https://etherlink.drpc.org" + ], + "14": [ + "https://rpc.ankr.com/flare", + "https://flare.drpc.org", + "https://flare.gateway.tenderly.co", + "https://flare-api.flare.network/ext/C/rpc", + "https://rpc.au.cc/flare" + ], + "81457": [ + "https://blast-rpc.publicnode.com", + "https://rpc.blast.io", + "https://blast.gateway.tenderly.co", + "https://blast.drpc.org" + ], + "1868": [ + "https://soneium.gateway.tenderly.co", + "https://soneium-rpc.publicnode.com", + "https://soneium.drpc.org", + "https://rpc.soneium.org" + ], + "196": [ + "https://xlayer.drpc.org", + "https://xlayerrpc.okx.com", + "https://rpc.xlayer.tech" + ] +} \ No newline at end of file diff --git a/debank_checker/diskcache.py b/debank_checker/diskcache.py new file mode 100644 index 0000000..ec3ae41 --- /dev/null +++ b/debank_checker/diskcache.py @@ -0,0 +1,71 @@ +""" +Простой дисковый кэш JSON в .cache/ корня проекта (между запусками). + +Хранит то, что меняется редко или никогда: список сетей Rabby, реестр +chainid.network, адреса Polymarket-proxy (детерминированы CREATE2). +""" +from __future__ import annotations + +import json +import os +import threading +import time +from pathlib import Path +from typing import Any + +CACHE_DIR = Path(__file__).resolve().parent.parent / ".cache" +_LOCK = threading.Lock() + + +def load(name: str, ttl: float | None = None) -> Any: + """Содержимое .cache/ или None (нет файла, устарел, битый).""" + path = CACHE_DIR / name + try: + if ttl is not None and time.time() - path.stat().st_mtime > ttl: + return None + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def save(name: str, data: Any) -> None: + """Атомарная запись (tmp + replace); ошибки записи игнорируются.""" + path = CACHE_DIR / name + with _LOCK: + try: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + f".{os.getpid()}.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + os.replace(tmp, path) + except OSError: + pass + + +class PersistentDict: + """Словарь, сохраняемый в .cache/ при каждом добавлении ключа.""" + + def __init__(self, name: str): + self._name = name + self._lock = threading.Lock() + self._data: dict[str, Any] | None = None + + def _ensure(self) -> dict[str, Any]: + if self._data is None: + loaded = load(self._name) + self._data = loaded if isinstance(loaded, dict) else {} + return self._data + + def get(self, key: str, default: Any = None) -> Any: + with self._lock: + return self._ensure().get(key, default) + + def __contains__(self, key: str) -> bool: + with self._lock: + return key in self._ensure() + + def set(self, key: str, value: Any) -> None: + with self._lock: + data = self._ensure() + data[key] = value + snapshot = dict(data) + save(self._name, snapshot) diff --git a/debank_checker/export/__init__.py b/debank_checker/export/__init__.py index 355970c..1e5dcba 100644 --- a/debank_checker/export/__init__.py +++ b/debank_checker/export/__init__.py @@ -1,4 +1,5 @@ """Экспорт: Excel.""" +from __future__ import annotations from debank_checker.export.excel import export_to_excel diff --git a/debank_checker/export/common.py b/debank_checker/export/common.py index 68a9994..c8cfd7e 100644 --- a/debank_checker/export/common.py +++ b/debank_checker/export/common.py @@ -1,6 +1,7 @@ """ Общая логика фильтрации и построения данных для экспорта """ +from __future__ import annotations from debank_checker.export.config import ExportConfig diff --git a/debank_checker/export/config.py b/debank_checker/export/config.py index 9bd1fec..508b38a 100644 --- a/debank_checker/export/config.py +++ b/debank_checker/export/config.py @@ -1,6 +1,7 @@ """ Конфигурация экспорта — что включать в результат """ +from __future__ import annotations from dataclasses import dataclass diff --git a/debank_checker/export/csv_exporter.py b/debank_checker/export/csv_exporter.py index cdc5cf0..27decd5 100644 --- a/debank_checker/export/csv_exporter.py +++ b/debank_checker/export/csv_exporter.py @@ -1,6 +1,7 @@ """ Экспорт результатов в CSV """ +from __future__ import annotations import csv import datetime diff --git a/debank_checker/export/excel.py b/debank_checker/export/excel.py index a37494c..47e79f0 100644 --- a/debank_checker/export/excel.py +++ b/debank_checker/export/excel.py @@ -1,6 +1,7 @@ """ Экспорт результатов в Excel """ +from __future__ import annotations import datetime from pathlib import Path diff --git a/debank_checker/export/json_exporter.py b/debank_checker/export/json_exporter.py index 717d23c..6ec1f22 100644 --- a/debank_checker/export/json_exporter.py +++ b/debank_checker/export/json_exporter.py @@ -1,6 +1,7 @@ """ Экспорт результатов в JSON """ +from __future__ import annotations import datetime import json diff --git a/debank_checker/parallel.py b/debank_checker/parallel.py new file mode 100644 index 0000000..60b32a7 --- /dev/null +++ b/debank_checker/parallel.py @@ -0,0 +1,119 @@ +""" +Примитивы параллельности: параллельный запуск независимых вызовов и +singleflight-мемоизация («один вычисляет — остальные ждут его результат»). +""" +from __future__ import annotations + +import threading +import time +from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait +from typing import Any, Callable, Hashable + + +def run_parallel(calls: dict[Hashable, Callable[[], Any]]) -> dict[Hashable, Any]: + """Выполняет вызовы одновременно; результат — {ключ: значение | исключение}. + + Исключения не бросаются, а возвращаются как значения — вызывающий решает, + какие из них фатальны. Последний вызов выполняется в текущем потоке. + Пул создаётся на каждый вызов, поэтому вложенные run_parallel не могут + взаимно заблокироваться. + """ + items = list(calls.items()) + out: dict[Hashable, Any] = {} + if not items: + return out + *rest, (last_key, last_fn) = items + pool = ThreadPoolExecutor(max_workers=len(rest)) if rest else None + try: + futures = {k: pool.submit(fn) for k, fn in rest} if pool else {} + try: + out[last_key] = last_fn() + except Exception as e: # noqa: BLE001 + out[last_key] = e + for k, fut in futures.items(): + try: + out[k] = fut.result() + except Exception as e: # noqa: BLE001 + out[k] = e + finally: + if pool: + pool.shutdown(wait=False) + return {k: out[k] for k, _ in items} + + +def first_success(fns: list[Callable[[], Any]], hedge_after: float) -> Any: + """Запускает fns[0]; следующая функция стартует, когда предыдущие упали или + не завершились за hedge_after секунд. Возвращает первый успешный результат; + если упали все — пробрасывает последнее исключение.""" + pool = ThreadPoolExecutor(max_workers=max(1, len(fns))) + try: + pending: set[Future] = set() + queue = list(fns) + last_exc: BaseException | None = None + while queue or pending: + if queue and (not pending or last_exc is not None): + pending.add(pool.submit(queue.pop(0))) + last_exc = None + done, pending = wait(pending, timeout=hedge_after if queue else None, return_when=FIRST_COMPLETED) + if not done and queue: + pending.add(pool.submit(queue.pop(0))) + continue + for fut in done: + try: + return fut.result() + except Exception as e: # noqa: BLE001 + last_exc = e + if last_exc is not None and not queue and not pending: + raise last_exc + raise RuntimeError("first_success: нет функций") + finally: + pool.shutdown(wait=False, cancel_futures=True) + + +def unwrap(value: Any) -> Any: + """Значение из run_parallel: исключение пробрасывается.""" + if isinstance(value, BaseException): + raise value + return value + + +class Memo: + """Потокобезопасный кэш с singleflight: одновременные запросы одного ключа + выполняют функцию один раз, остальные ждут её результат. + + Ошибка не кэшируется: ждущие получают то же исключение, следующий вызов + вычисляет заново. ttl=None — хранить до конца жизни объекта. + """ + + def __init__(self, ttl: float | None = None): + self._ttl = ttl + self._lock = threading.Lock() + self._items: dict[Hashable, tuple[float, Future]] = {} + + def get(self, key: Hashable, fn: Callable[[], Any]) -> Any: + now = time.monotonic() + with self._lock: + hit = self._items.get(key) + if hit and (self._ttl is None or not hit[1].done() or now - hit[0] < self._ttl): + fut, owner = hit[1], False + else: + fut, owner = Future(), True + self._items[key] = (now, fut) + if not owner: + return fut.result() + try: + value = fn() + except BaseException as e: + with self._lock: + if self._items.get(key, (0, None))[1] is fut: + del self._items[key] + fut.set_exception(e) + raise + with self._lock: + self._items[key] = (time.monotonic(), fut) + fut.set_result(value) + return value + + def clear(self) -> None: + with self._lock: + self._items.clear() diff --git a/debank_checker/proxy/__init__.py b/debank_checker/proxy/__init__.py index cff54de..58d371c 100644 --- a/debank_checker/proxy/__init__.py +++ b/debank_checker/proxy/__init__.py @@ -1,4 +1,5 @@ """Прокси: загрузка, ротация, rate limiting.""" +from __future__ import annotations from debank_checker.proxy.manager import ProxyManager, load_proxies diff --git a/debank_checker/proxy/manager.py b/debank_checker/proxy/manager.py index 531820e..d943ec3 100644 --- a/debank_checker/proxy/manager.py +++ b/debank_checker/proxy/manager.py @@ -1,13 +1,14 @@ """ Менеджер прокси: загрузка, ротация, rate limiting """ +from __future__ import annotations import threading import time from collections import deque from pathlib import Path -from debank_checker.config import DEBUG, PROXIES_FILE, RATE_LIMIT_REQ_PER_MIN +from debank_checker.config import DEBUG, PROXIES_FILE, PROXY_COOLDOWN_429_SEC, RATE_LIMIT_REQ_PER_MIN def load_proxies(path: str | Path = PROXIES_FILE) -> list[str]: @@ -34,11 +35,37 @@ def load_proxies(path: str | Path = PROXIES_FILE) -> list[str]: return proxies +def drop_dead_proxies(proxies: list[str], timeout: float = 3.0) -> tuple[list[str], list[str]]: + """Параллельная предпроверка: прокси, отвергающие подключение (407 / + CONNECT failed), отбрасываются. Любой HTTP-ответ — прокси жив; таймаут — + тоже оставляем (мог просто притормозить). Заодно прогревает keep-alive + соединения к api.rabby.io. Возвращает (живые, мёртвые).""" + from concurrent.futures import ThreadPoolExecutor + + from debank_checker.api import http + + def alive(proxy: str) -> bool: + try: + http.get("https://api.rabby.io/", proxy, timeout=timeout) + except http.ProxyDead: + return False + except Exception: # noqa: BLE001 + pass + return True + + if not proxies: + return [], [] + with ThreadPoolExecutor(max_workers=min(len(proxies), 128)) as ex: + flags = list(ex.map(alive, proxies)) + return ([p for p, ok in zip(proxies, flags) if ok], + [p for p, ok in zip(proxies, flags) if not ok]) + + class ProxyManager: """ Ротация прокси с учётом rate limit. get_proxy() возвращает прокси с наименьшей нагрузкой. - report_timeout() временно исключает прокси из выдачи (см. docs/DEBUG_REPORT.md). + report_timeout() / report_rate_limited() временно исключают прокси из выдачи. """ PROXY_COOLDOWN_AFTER_TIMEOUT = 60 # секунд, не выдавать прокси после таймаута @@ -64,10 +91,22 @@ def __init__( def report_timeout(self, proxy: str | None) -> None: """Исключить прокси из выдачи на PROXY_COOLDOWN_AFTER_TIMEOUT сек.""" - if not proxy: + self._cooldown(proxy, self.PROXY_COOLDOWN_AFTER_TIMEOUT) + + def report_rate_limited(self, proxy: str | None) -> None: + """Прокси получил 429/403 — не выдавать PROXY_COOLDOWN_429_SEC сек.""" + self._cooldown(proxy, PROXY_COOLDOWN_429_SEC) + + def report_dead(self, proxy: str | None) -> None: + """Прокси отверг подключение (407) — исключить до конца запуска.""" + self._cooldown(proxy, float("inf")) + + def _cooldown(self, proxy: str | None, seconds: float) -> None: + if not proxy or not self._proxies: return with self._lock: - self._timeout_until[proxy] = time.time() + self.PROXY_COOLDOWN_AFTER_TIMEOUT + until = time.time() + seconds + self._timeout_until[proxy] = max(self._timeout_until.get(proxy, 0), until) def get_proxy(self) -> str | None: """Возвращает прокси с наименьшей нагрузкой (round-robin с учётом rate limit).""" diff --git a/debank_checker/ui/__init__.py b/debank_checker/ui/__init__.py index 06fcadf..42b0cd6 100644 --- a/debank_checker/ui/__init__.py +++ b/debank_checker/ui/__init__.py @@ -1,4 +1,5 @@ """UI: баннер, логирование.""" +from __future__ import annotations from debank_checker.ui.banner import create_progress_bar, show from debank_checker.ui.logger import error, info, success, warning diff --git a/debank_checker/ui/banner.py b/debank_checker/ui/banner.py index e35bbf5..b9b53e3 100644 --- a/debank_checker/ui/banner.py +++ b/debank_checker/ui/banner.py @@ -1,9 +1,12 @@ """ ASCII-заставка в стиле Season7 """ +from __future__ import annotations import sys +from debank_checker import __version__ + # ANSI цвета RESET = "\033[0m" BRIGHT = "\033[1m" @@ -59,7 +62,7 @@ def create_subtitle() -> str: """Подзаголовок.""" return "\n".join( [ - _center_text_in_banner(f"{YELLOW}{BRIGHT}BALANCE CHECKER v1.1.0{RESET}"), + _center_text_in_banner(f"{YELLOW}{BRIGHT}BALANCE CHECKER v{__version__}{RESET}"), _center_text_in_banner(f"{DIM}EVM · Tokens · DeFi · NFT{RESET}"), _center_text_in_banner(f"{DIM}TG: https://t.me/privatekey_ai{RESET}"), ] diff --git a/debank_checker/ui/logger.py b/debank_checker/ui/logger.py index 15131ba..53bd3aa 100644 --- a/debank_checker/ui/logger.py +++ b/debank_checker/ui/logger.py @@ -2,6 +2,7 @@ Логирование в формате: YYYY-MM-DD HH:MM:SS | LEVEL | Сообщение Цвета: INFO — белый, WARNING — оранжевый, SUCCESS — зелёный, ERROR — красный """ +from __future__ import annotations import sys from datetime import datetime diff --git a/debank_checker/ui/menu.py b/debank_checker/ui/menu.py index 3589f17..794302a 100644 --- a/debank_checker/ui/menu.py +++ b/debank_checker/ui/menu.py @@ -3,6 +3,9 @@ Вызывается после проверки кошельков — данные уже получены. Двухшаговый выбор: сначала сеть, затем токен/NFT/протокол в этой сети. """ +from __future__ import annotations + +from typing import Callable import questionary @@ -119,11 +122,14 @@ def _build_protocol_options_for_chain(results: list[dict] | None, chain: str) -> def _select(title: str, options: list[str]) -> str | None: - """Выбор из списка с помощью questionary.""" + """Выбор из списка с помощью questionary. + + unsafe_ask: Ctrl+C пробрасывается как KeyboardInterrupt (выход без + сохранения), а не превращается в пустой ответ с выбором по умолчанию. + """ if not options: return None - result = questionary.select(title, choices=options).ask() - return result + return questionary.select(title, choices=options).unsafe_ask() FORMAT_OPTIONS = [ @@ -152,15 +158,18 @@ def _select(title: str, options: list[str]) -> str | None: ] -def show_menu(results: list[dict] | None = None) -> ExportConfig: +def show_menu(results: list[dict] | None = None, ensure_nfts: Callable[[], None] | None = None) -> ExportConfig: """ Интерактивное меню: двухшаговый выбор. Шаг 1: выбор сети. Шаг 2: выбор токена/NFT/протокола в этой сети. + ensure_nfts — дождаться фоновой загрузки NFT перед выбором NFT-коллекций. """ choice_str = _select("Что экспортировать? (↑↓ — выбор, Enter — подтвердить)", MAIN_OPTIONS) if not choice_str: choice_str = MAIN_OPTIONS[0] choice = int(choice_str.split(".")[0].strip()) + if choice in (6, 7) and ensure_nfts: + ensure_nfts() token_filter = None nft_filter = None diff --git a/docs/PHANTOM_BALANCES.md b/docs/PHANTOM_BALANCES.md new file mode 100644 index 0000000..68f6029 --- /dev/null +++ b/docs/PHANTOM_BALANCES.md @@ -0,0 +1,137 @@ +# Отчёт: причина завышенных балансов и исправление (21.09.2026) + +> Исторический отчёт расследования. Актуальное поведение и параметры — в +> `README.md` и `CHANGELOG.md` (v2.0.0: проверка ускорена, логика защиты та же; +> поле `onchain_unverified_usd` удалено — оно всегда было 0). + +## Симптом + +С 11.09.2026 суммарный баланс тестового набора кошельков в прогонах вырос +в сотни раз по сравнению с честными прогонами до этой даты. В каждом прогоне +от 13 до 33 % кошельков с фантомами, причём **одинаковые суммы у разных адресов** +(8 977.99 у двух, 15 796.84 у четырёх, 4 234 681.59 у двух и т.д.). + +## Причина + +Живые запросы (однопоточно, без прокси, 1.5 с на кошелёк — т.е. без какой-либо +нагрузки) показали, что **бэкенд Rabby возвращает чужие данные**: + +| Эндпоинт | Что происходит | Пример | +|---|---|---| +| `/v1/user/total_balance` | агрегат содержит суммы чужого кошелька, «липнет» минуты-часы, чужие сети — `arb`/`eth` (мосты Hyperliquid/Lighter) | кошелёк A: $94 847.77 стабильно при токенах $25.87; час назад — $25.88 | +| `/v1/user/complex_app_list` | случайные app-chain позиции (Hyperliquid, Lighter, Polymarket, Opinion) с **нашим** `user_addr`, но чужими суммами; меняются от запроса к запросу | кошелёк B: `[]` → Hyperliquid $18 668 → `[]`; кошелёк C: 1.98 млн HYPE на кошельке с $27 | +| `/v1/user/token_list` (по сети) | отдельные чужие токены | кошелёк D: arb $1 017 (on-chain: 0 ETH, 0 USDC); одинаковые $2.21 ETH@hood у двух адресов | +| `/v1/user/cache_token_list` | в основном стабилен (совпадает с журналом за недели и с on-chain), изредка чужой токен | `ETH@hood $2.29`, `USDC@arc $2.27` — on-chain 0 | + +Независимая проверка: **Hyperliquid API** (`api.hyperliquid.xyz/info`: +`clearinghouseState`, `spotClearinghouseState`, `delegatorSummary`) для всех +«заражённых» адресов даёт **0**; **Lighter API** — `account not found`; +**Polymarket data-api** по вычисленным proxy-кошелькам — `value 0, positions 0` +(в т.ч. для адреса, которому Rabby приписывал $857); +публичные RPC (Arbitrum, Soneium, Base, BSC, Ethereum) подтверждают только +токены из `cache_token_list`. + +Скорость прогонов не менялась с августа, прокси и +воркеры на результат не влияют — триггер серверный. Старая логика +«`total_usd_value` — авторитетный агрегат» и была точкой входа фантомов: +она принимала чужой агрегат как итог, а корроборация с `MIN_AGREE=1` ничего +не проверяла. + +## Исправление + +Итог собирается только из проверенных компонентов (`debank_checker/checker.py`): + +1. **Токены** — из `cache_token_list` (core, не скам, `is_wallet`), затем + **on-chain проверка** каждого токена ≥ $0.5 через публичные JSON-RPC + (`api/onchain.py`, реестр chainid.network + встроенные RPC): фантом + отбрасывается, количество исправляется по сети, недоступный RPC — токен + остаётся с пометкой (`onchain_unverified_usd`). +2. **Сверка по сетям**: если агрегат Rabby по сети выше токенов + EVM-позиций, + кандидаты из `token_list` принимаются **только после on-chain подтверждения** + (устаревший кэш) — иначе разница считается фантомом агрегата. +3. **EVM DeFi-позиции** — пересчёт по `asset_token_list`, `min(api, recalc)`. +4. **App-chain позиции** — только из нативных API Hyperliquid, Lighter и + Polymarket (`api/hyperliquid_client.py`, `api/lighter_client.py`, + `api/polymarket_client.py`; fail-closed). Для Polymarket proxy-кошелёк + вычисляется через `computeProxyAddress` Safe-фабрики на Polygon, стоимость — + `data-api.polymarket.com/value` + USDC.e на proxy. Значения Rabby для этих + приложений игнорируются; прочие app-chain (Opinion…) в итог не входят и + попадают в `unverified_usd` + примечание. +5. **Агрегат Rabby** — только контроль: `aggregate_usd`, `aggregate_agrees`, + примечание «агрегат отклонён… необъяснённые сети». +6. **Корроборация**: `CORROBORATION_MIN_AGREE=2` — итог принимается, когда две + независимые выборки сошлись; иначе `UNVERIFIED` (в сумму не входит). +7. Транзиентные ошибки (timeout/429/reset) — повтор с паузой; `REQUEST_TIMEOUT` + 8 с; `MAX_WORKERS` 50. + +Дополнительно: `audit_passes.py` — N полных проходов с отчётом стабильности; +колонка «Примечание» в Excel/CSV; прямой режим `DBC_DIRECT=1` (по умолчанию +прокси обязательны); совместимость с Python 3.9 (`from __future__ import annotations`); +35 офлайн-тестов (`tests/`), включая сценарии: заражённый агрегат, фантомная +Hyperliquid-позиция, фантомный токен в кэше, кандидат из `token_list` без +on-chain подтверждения, расхождение выборок → `UNVERIFIED`. + +## Результаты живых прогонов (21.09.2026, прямой режим, 8 воркеров) + +`audit_passes.py` — **5 полных проходов по тестовому набору**, в +каждом проходе на каждый кошелёк две согласованные выборки: + +| Проход | OK | Отклонение суммы от среднего | +|---|---|---| +| 1 | 100 % | −0.05 % | +| 2 | 100 % | +0.01 % | +| 3 | 100 % | −0.02 % | +| 4 | 100 % | +0.19 % | +| 5 | 100 % | −0.12 % | + +**Все кошельки STABLE** (5 согласованных OK-результатов); максимальный +разброс между проходами — < 0.5 % у самого крупного кошелька (движение цены ETH). Токенов, не подтверждённых on-chain, — $0. + +За 5 проходов отклонено: 296 фантомных Hyperliquid-позиций, 61 Lighter, +Polymarket-претензии (все — нативно 0); on-chain отброшено 9 чужих токенов +(ETH@eth, USDC@base, LINEA, REZ, BOBA, USDe). Rabby на ~2 500 запросах +вернул 403/429 на `total_balance` — итог от него не зависит. + +Контрольный проход **финальной** версией (с нативной проверкой Polymarket): +100 % OK, каждый кошелёк в +пределах допуска пяти основных проходов; Polymarket по 19 претензиям Rabby — +нативно 0. Для сравнения: сумма доступных агрегатов Rabby в этом проходе +превышала проверенный итог примерно в 33 раза (фантом). + +Проверенный итог согласуется с честными прогонами до 11.09 (с поправкой на +цены ETH). + +## Версия 2 (21.09.2026, вечер): ноль настроек, 500 воркеров, 100 % on-chain + +- **Встроенный реестр RPC** `debank_checker/data/rpc_registry.json`: собрано + 1 688 кандидатов (chainid.network + шаблоны publicnode/drpc/1rpc/blastapi/ + tenderly/thirdweb/… + ручной список), каждый проверен живьём (`eth_chainId` + + `eth_getBalance`). Рабочих нод — **для всех 73 сетей Rabby**, от 1 (zircuit, + sei, gravity, cfx, dbk, sophon, citrea) до 17 (bsc). Генератор: + `tools/build_rpc_registry.py`. Поверх — живой chainid.network и `rpc.txt`. +- **Исчерпывающий перебор**: при проверке токена перебираются все ноды сети, + 3 раунда с паузами; сбойная нода получает cooldown 45 с. +- **On-chain проверка обязательна**: токен ≥ $0.5, который не удалось + подтвердить (сеть недоступна), больше не «остаётся с пометкой» — выборка + не засчитывается, повторяется, и при полном провале кошелёк получает + `ERROR`/`UNVERIFIED`. В итог не попадает ничего непроверенного. +- **500 воркеров**: `MAX_WORKERS=500`, `min(500, кошельков, прокси×5)`. + Лимиты Rabby, Hyperliquid (9 req/s), Polymarket/Lighter (4 req/s) и RPC + (6 параллельных на хост) ведутся **на каждый прокси**; нативные API и RPC + ходят через тот же прокси, что и Rabby (`APPCHAIN_VIA_PROXY`). +- **HTTP 429 / 5xx**: клиент Rabby повторяет с `Retry-After` или 3·6·12 с; + Polymarket/Lighter/Hyperliquid — до 5 попыток; `check_wallet` после 429 ждёт + дольше, чем после сетевой ошибки. +- Прямой режим — 8 воркеров и 4 req/s к Rabby (один IP). + +## Ограничения + +- Позиции на app-chain без нативной проверки (Opinion и т.п.) в итог не + входят — они видны в примечании (`unverified_usd`). +- В прямом режиме с одного IP Rabby после ~2 500 запросов за 4 минуты вернул + HTTP 403 на `total_balance`/`used_chain_list` (остальные эндпоинты работали). + Итог от агрегата не зависит, поэтому выборки продолжаются без него + (`AGGREGATE_OPTIONAL`); добавлен лимит `RABBY_DIRECT_RATE_PER_SEC`. Через + прокси действует прежний лимит 60 req/мин на прокси. +- Сети без публичного RPC: токен остаётся с пометкой «не проверен on-chain». +- NFT не оцениваются в USD (как и раньше). diff --git a/main.py b/main.py index f077d2e..f7696ef 100644 --- a/main.py +++ b/main.py @@ -1,30 +1,35 @@ """ DeBank Checker — точка входа (балансы через Rabby API) """ +from __future__ import annotations +import os import sys import threading import time -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from debank_checker.checker import check_wallet +from debank_checker import __version__ +from debank_checker.checker import check_wallet, pending_nfts, wait_nfts from debank_checker.config import ( + ALLOW_DIRECT, DEBUG, + DIRECT_WORKERS, MAX_WORKERS, OUTPUT_DIR, - PROXIES_FILE, PROXY_MULTIPLIER, WALLETS_FILE, ) from debank_checker.export.csv_exporter import export_to_csv from debank_checker.export.excel import export_to_excel from debank_checker.export.json_exporter import export_to_json -from debank_checker.proxy.manager import load_proxies, ProxyManager +from debank_checker.proxy.manager import ProxyManager, drop_dead_proxies, load_proxies from debank_checker.ui.banner import create_progress_bar, show -from debank_checker.ui.logger import error, info, success +from debank_checker.ui.logger import error, info, success, warning from debank_checker.ui.menu import ask_continue, ask_format, show_menu + def setup_encoding() -> None: """UTF-8 и colorama для Windows.""" if sys.platform == "win32": @@ -41,42 +46,23 @@ def setup_encoding() -> None: def load_wallets(path: str | Path = WALLETS_FILE) -> list[str]: - """Читает адреса из файла.""" + """Читает адреса из файла (пустые строки и # — пропускаются).""" path = Path(path) - wallets = [] if not path.exists(): - return wallets + return [] with open(path, encoding="utf-8") as f: - for line in f: - line = line.strip() - if line and not line.startswith("#"): - wallets.append(line) - return wallets + return [line.strip() for line in f if line.strip() and not line.strip().startswith("#")] -def main() -> None: - setup_encoding() - show() +def worker_count(wallets: int, proxies: int) -> int: + """min(MAX_WORKERS, кошельков, прокси × PROXY_MULTIPLIER); без прокси — DIRECT_WORKERS.""" + return max(1, min(MAX_WORKERS, wallets, proxies * PROXY_MULTIPLIER if proxies else DIRECT_WORKERS)) - wallets = load_wallets() - proxies = load_proxies() - if not wallets: - error("Нет кошельков для проверки. Добавь адреса в wallets.txt") - sys.exit(1) - - if not proxies: - error("Прокси обязательны. Добавь прокси в proxy.txt") - sys.exit(1) - - info(f"Кошельков: {len(wallets)} | Прокси: {len(proxies)}") - - max_workers = min(MAX_WORKERS, len(wallets), len(proxies) * PROXY_MULTIPLIER) - max_workers = max(1, max_workers) - info(f"Параллельных воркеров: {max_workers}") - - proxy_manager = ProxyManager(proxies) - results: list[dict] = [None] * len(wallets) +def check_all(wallets: list[str], proxy_manager: ProxyManager, workers: int, + on_done=None) -> list[dict]: + """Проверяет все кошельки параллельно; on_done(done, total) — после каждого.""" + results: list[dict] = [None] * len(wallets) # type: ignore[list-item] completed = [0] lock = threading.Lock() @@ -85,39 +71,79 @@ def process(idx: int, address: str) -> None: row = check_wallet(address, proxy_manager, wallet_idx=idx) results[idx] = row if DEBUG: - elapsed = time.perf_counter() - start - sys.stderr.write(f"[DEBUG] #{idx} DONE | {elapsed:.2f}s total | status={row['status']}\n") + sys.stderr.write(f"[DEBUG] #{idx} DONE | {time.perf_counter() - start:.2f}s total" + f" | status={row['status']}\n") sys.stderr.flush() with lock: completed[0] += 1 - done = completed[0] - total = len(wallets) - bar = create_progress_bar(done, total) - print(f"\r{bar}", end="", flush=True) + if on_done: + on_done(completed[0], len(wallets)) - print("\r" + create_progress_bar(0, len(wallets)), end="", flush=True) + with ThreadPoolExecutor(max_workers=workers) as executor: + for fut in [executor.submit(process, i, w) for i, w in enumerate(wallets)]: + fut.result() + return results + + +def ensure_nfts() -> None: + """Дождаться фоновой загрузки NFT (нужны меню NFT и экспорту).""" + left = pending_nfts() + if left: + info(f"Догружаю NFT: осталось {left} кошельков...") + if wait_nfts(timeout=120): + warning(f"NFT не догружены у {pending_nfts()} кошельков — в экспорте их NFT будут пустыми") - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = { - executor.submit(process, i, w): i - for i, w in enumerate(wallets) - } - for future in as_completed(futures): - future.result() +def main() -> None: + setup_encoding() + show() + + wallets = load_wallets() + proxies = load_proxies() + + if not wallets: + error("Нет кошельков для проверки. Добавь адреса в wallets.txt") + sys.exit(1) + + if not proxies and not ALLOW_DIRECT: + error("Прокси обязательны. Добавь прокси в proxy.txt (или DBC_DIRECT=1 для прямого режима)") + sys.exit(1) + + if proxies: + total_proxies = len(proxies) + proxies, _ = drop_dead_proxies(proxies) # мёртвые исключаются молча + if not proxies: + error("Ни один прокси не работает — проверь proxy.txt") + sys.exit(1) + info(f"v{__version__} | Кошельков: {len(wallets)} | " + + (f"Рабочие прокси: {len(proxies)} из {total_proxies}" if proxies else "Прокси: нет (прямой режим)")) + workers = worker_count(len(wallets), len(proxies)) + info(f"Параллельных воркеров: {workers}") + + started = time.perf_counter() + print("\r" + create_progress_bar(0, len(wallets)), end="", flush=True) + results = check_all(wallets, ProxyManager(proxies), workers, + on_done=lambda done, total: print(f"\r{create_progress_bar(done, total)}", + end="", flush=True)) print() - ok_count = sum(1 for r in results if r and r["status"] == "OK") - total_sum = sum(r["total_usd"] for r in results if r and r["status"] == "OK") + ok_count = sum(1 for r in results if r["status"] == "OK") + unverified = sum(1 for r in results if r["status"] == "UNVERIFIED") + total_sum = sum(r["total_usd"] for r in results if r["status"] == "OK") - info(f"Итого: {ok_count}/{len(results)} успешно | Суммарный баланс: ${total_sum:,.2f}") + info(f"Итого: {ok_count}/{len(results)} подтверждено | Суммарный баланс: ${total_sum:,.2f}" + f" | {time.perf_counter() - started:.1f} с") + if unverified: + info(f"Не подтверждено (UNVERIFIED, в сумму не входят): {unverified}") output_path = Path(OUTPUT_DIR) output_path.mkdir(parents=True, exist_ok=True) while True: - export_config = show_menu(results) + export_config = show_menu(results, ensure_nfts=ensure_nfts) fmt = ask_format() + if export_config.nft: # без NFT в экспорте ждать их догрузки не нужно + ensure_nfts() if fmt == "csv": out_path = export_to_csv(results, output_path, config=export_config) @@ -135,12 +161,21 @@ def process(idx: int, address: str) -> None: info("Готово.") +def _exit(code: int) -> None: + """Немедленный выход. sys.exit ждал бы фоновые потоки (догрузка NFT, + запасные запросы к зависшим выборкам) — Ctrl+C «зависал» бы до их конца.""" + sys.stdout.flush() + sys.stderr.flush() + os._exit(code) + + if __name__ == "__main__": try: main() except KeyboardInterrupt: info("Прервано пользователем") - sys.exit(0) + _exit(0) except Exception as e: error(f"Критическая ошибка: {e}") - sys.exit(1) + _exit(1) + _exit(0) diff --git a/tests/test_menu.py b/tests/test_menu.py new file mode 100644 index 0000000..02201e2 --- /dev/null +++ b/tests/test_menu.py @@ -0,0 +1,18 @@ +"""Меню экспорта: Ctrl+C прерывает работу, а не запускает экспорт по умолчанию.""" +from __future__ import annotations + +import pytest + +from debank_checker.ui import menu + + +class _Interrupted: + def unsafe_ask(self): + raise KeyboardInterrupt + + +@pytest.mark.parametrize("call", [lambda: menu.show_menu([]), menu.ask_format, menu.ask_continue]) +def test_ctrl_c_in_menu_is_not_swallowed(monkeypatch, call): + monkeypatch.setattr(menu.questionary, "select", lambda *a, **k: _Interrupted()) + with pytest.raises(KeyboardInterrupt): + call() diff --git a/tests/test_parallel.py b/tests/test_parallel.py new file mode 100644 index 0000000..7e06af5 --- /dev/null +++ b/tests/test_parallel.py @@ -0,0 +1,72 @@ +"""Тесты примитивов параллельности (debank_checker/parallel.py).""" +from __future__ import annotations + +import threading +import time + +import pytest + +from debank_checker.parallel import Memo, first_success, run_parallel, unwrap + + +def test_run_parallel_runs_concurrently_and_keeps_keys(): + barrier = threading.Barrier(3, timeout=2) + + def job(v): + return lambda: (barrier.wait(), v)[1] + + out = run_parallel({"a": job(1), "b": job(2), "c": job(3)}) + assert out == {"a": 1, "b": 2, "c": 3} + + +def test_run_parallel_returns_exceptions_as_values(): + out = run_parallel({"ok": lambda: 1, "bad": lambda: 1 / 0}) + assert out["ok"] == 1 + assert isinstance(out["bad"], ZeroDivisionError) + with pytest.raises(ZeroDivisionError): + unwrap(out["bad"]) + + +def test_memo_singleflight_calls_once(): + calls = [] + memo = Memo() + + def slow(): + calls.append(1) + time.sleep(0.1) + return 42 + + out = run_parallel({i: (lambda: memo.get("k", slow)) for i in range(5)}) + assert set(out.values()) == {42} + assert len(calls) == 1 + + +def test_memo_does_not_cache_errors(): + memo = Memo() + with pytest.raises(RuntimeError): + memo.get("k", lambda: (_ for _ in ()).throw(RuntimeError("x"))) + assert memo.get("k", lambda: 7) == 7 + + +def test_memo_ttl_expires(): + memo = Memo(ttl=0.05) + assert memo.get("k", lambda: 1) == 1 + assert memo.get("k", lambda: 2) == 1 + time.sleep(0.06) + assert memo.get("k", lambda: 3) == 3 + + +def test_first_success_hedges_slow_call(): + started = time.perf_counter() + result = first_success([lambda: (time.sleep(2), "slow")[1], lambda: "fast"], hedge_after=0.05) + assert result == "fast" + assert time.perf_counter() - started < 1.0 + + +def test_first_success_moves_on_after_failure(): + assert first_success([lambda: 1 / 0, lambda: "ok"], hedge_after=10) == "ok" + + +def test_first_success_raises_last_error_when_all_fail(): + with pytest.raises(KeyError): + first_success([lambda: 1 / 0, lambda: {}["x"]], hedge_after=10) diff --git a/tests/test_rabby_client.py b/tests/test_rabby_client.py index e00c737..5ce9e7f 100644 --- a/tests/test_rabby_client.py +++ b/tests/test_rabby_client.py @@ -5,6 +5,7 @@ клиента, а не вычислено нашей же формулой. Совпадение доказывает, что схема подписи верна. """ +from __future__ import annotations import json from pathlib import Path diff --git a/tests/test_rabby_snapshot.py b/tests/test_rabby_snapshot.py index a7019c2..08e0483 100644 --- a/tests/test_rabby_snapshot.py +++ b/tests/test_rabby_snapshot.py @@ -1,8 +1,12 @@ -"""Тесты сборки снапшота баланса через Rabby (_fetch_snapshot). +"""Тесты сборки снапшота баланса через Rabby (_fetch_snapshot) и корроборации. -Мокаем ТОЛЬКО внешний HTTP-клиент (RabbyClient) — граница системы. Логика -маппинга ответов в result-dict тестируется как есть. +Мокаем ТОЛЬКО внешние клиенты (RabbyClient, нативные app-chain API) — границу +системы. Логика маппинга ответов в result-dict тестируется как есть. """ +from __future__ import annotations + +import itertools +import time import debank_checker.checker as checker @@ -10,15 +14,16 @@ class FakeRabbyClient: """Заглушка Rabby API с заранее заданными ответами эндпоинтов.""" - def __init__(self, proxy: str): + def __init__(self, proxy: str | None): self.proxy = proxy def get_total_balance(self, address: str, is_core: bool = True) -> dict: + # честный агрегат: 371.32 (USDC eth) + 8.68 (ETH arb) + 8.68 (позиция на arb) return { - "total_usd_value": 380.0, + "total_usd_value": 388.68, "chain_list": [ {"id": "eth", "usd_value": 371.32}, - {"id": "arb", "usd_value": 8.68}, + {"id": "arb", "usd_value": 17.36}, {"id": "bsc", "usd_value": 0.0}, # нулевую сеть пропускаем ], } @@ -40,7 +45,7 @@ def get_cache_token_list(self, address: str) -> list: ] def get_token_list(self, address: str, chain_id: str, is_all: bool = False) -> list: - """Фолбэк: по-сетевой список (используется при сбое кэш-эндпоинта).""" + """Фолбэк / свежий по-сетевой список.""" data = { "eth": [ {"symbol": "USDC", "chain": "eth", "amount": 371.32, "price": 1.0, @@ -57,7 +62,7 @@ def get_token_list(self, address: str, chain_id: str, is_all: bool = False) -> l def get_complex_app_list(self, address: str) -> list: return [ { - "name": "Hyperliquid", "logo_url": "l", + "id": "gmx", "name": "GMX", "logo_url": "l", "portfolio_item_list": [ { "name": "Rewards", @@ -84,15 +89,251 @@ def get_collection_list(self, address: str, is_all: bool = True) -> list: ] -def _patch(monkeypatch): - monkeypatch.setattr(checker, "RabbyClient", FakeRabbyClient) +def _no_appchain(address, proxy): + return {"hyperliquid": {"total_usd": 0.0, "details": []}, + "lighter": {"total_usd": 0.0, "details": []}, + "polymarket": {"total_usd": 0.0, "details": []}} + + +def _onchain_matches(token, address, chain_map, proxy=None): + """On-chain количество совпадает с Rabby.""" + return float(token.get("amount") or 0) + +def _patch(monkeypatch, client=FakeRabbyClient, appchain=_no_appchain, onchain=_onchain_matches): + monkeypatch.setattr(checker, "RabbyClient", client) + monkeypatch.setattr(checker, "_appchain_positions", appchain) + monkeypatch.setattr(checker, "_onchain_verify_token", onchain) + monkeypatch.setattr(checker, "_chain_map", lambda client: {"eth": {"evm": 1, "native": "eth"}, + "arb": {"evm": 42161, "native": "arb"}}) -def test_total_usd_comes_from_authoritative_total_balance(monkeypatch): - """total_usd берётся из total_usd_value напрямую, НЕ суммируется вручную.""" + +def test_total_is_sum_of_verified_components(monkeypatch): + """total_usd = токены + проверенные позиции; агрегат согласован.""" _patch(monkeypatch) snap = checker._fetch_snapshot("0xabc", "http://proxy") - assert snap["total_usd"] == 380.0 + assert round(snap["total_usd"], 2) == 388.68 + assert snap["aggregate_usd"] == 388.68 + assert snap["aggregate_agrees"] is True + assert snap["notes"] == [] + + +def test_contaminated_aggregate_is_rejected(monkeypatch): + """Агрегат Rabby с чужой суммой НЕ становится итогом; расхождение — в примечании.""" + class Contaminated(FakeRabbyClient): + def get_total_balance(self, address, is_core=True): + return {"total_usd_value": 246977.52, + "chain_list": [{"id": "eth", "usd_value": 371.32}, + {"id": "arb", "usd_value": 246606.2}]} + + _patch(monkeypatch, Contaminated) + snap = checker._fetch_snapshot("0xabc", "http://proxy") + assert round(snap["total_usd"], 2) == 388.68 + assert snap["aggregate_agrees"] is False + assert any("отклонён" in n for n in snap["notes"]) + assert "arb" in snap["notes"][-1] + + +def test_stale_token_cache_refreshed_per_chain(monkeypatch): + """Агрегат по сети выше кэша токенов, свежий token_list подтверждает → берём свежие токены.""" + class StaleCache(FakeRabbyClient): + def get_total_balance(self, address, is_core=True): + return {"total_usd_value": 488.68, + "chain_list": [{"id": "eth", "usd_value": 471.32}, {"id": "arb", "usd_value": 17.36}]} + + def get_token_list(self, address, chain_id, is_all=False): + if chain_id == "eth": + return [{"id": "0xdai", "symbol": "DAI", "chain": "eth", "amount": 100.0, "price": 1.0, + "is_verified": True, "is_core": True, "is_scam": False}] + return super().get_token_list(address, chain_id, is_all) + + _patch(monkeypatch, StaleCache) + snap = checker._fetch_snapshot("0xabc", "http://proxy") + assert round(snap["tokens_usd"], 2) == 480.0 + assert snap["aggregate_agrees"] is True + assert any("устарел" in n and "on-chain подтверждены" in n for n in snap["notes"]) + + +def test_phantom_token_from_cache_dropped_by_onchain(monkeypatch): + """Токен из cache_token_list с on-chain балансом 0 отбрасывается.""" + def onchain(token, address, chain_map, proxy=None): + return 0.0 if token["symbol"] == "USDC" else float(token["amount"]) + + _patch(monkeypatch, onchain=onchain) + snap = checker._fetch_snapshot("0xabc", "http://proxy") + assert round(snap["tokens_usd"], 2) == 8.68 + assert any("фантомный токен" in n for n in snap["notes"]) + assert snap["aggregate_agrees"] is False + + +def test_token_amount_corrected_from_chain(monkeypatch): + """Расхождение количества исправляется по данным сети.""" + def onchain(token, address, chain_map, proxy=None): + return 100.0 if token["symbol"] == "USDC" else float(token["amount"]) + + _patch(monkeypatch, onchain=onchain) + snap = checker._fetch_snapshot("0xabc", "http://proxy") + assert round(snap["tokens_usd"], 2) == 108.68 + assert any("по данным сети" in n for n in snap["notes"]) + + +def test_unverifiable_token_fails_snapshot(monkeypatch): + """RPC сети недоступен → выборка не засчитывается (OnchainCheckFailed), а не тихий приём.""" + def onchain(token, address, chain_map, proxy=None): + raise checker.OnchainCheckFailed("все ноды недоступны") + + _patch(monkeypatch, onchain=onchain) + try: + checker._fetch_snapshot("0xabc", "http://proxy") + except checker.OnchainCheckFailed as e: + assert "USDC@eth" in str(e) + return + raise AssertionError("ожидалось OnchainCheckFailed") + + +def test_unverifiable_chain_ends_as_unverified(monkeypatch): + """Если RPC сети не поднимаются за все попытки — статус UNVERIFIED, не OK и не ноль.""" + def onchain(token, address, chain_map, proxy=None): + raise checker.OnchainCheckFailed("все ноды недоступны") + + _patch(monkeypatch, onchain=onchain) + monkeypatch.setattr(checker, "RETRY_ATTEMPTS", 3) + monkeypatch.setattr(checker, "CORROBORATION_MAX_FETCHES", 1) + monkeypatch.setattr(checker, "RETRY_BACKOFF_SEC", 0.0) + monkeypatch.setattr(checker, "RAW_LOG_ENABLED", False) + res = checker.check_wallet("0xabc", _PM()) + assert res["status"] == "ERROR" + assert "USDC@eth" in res["error"] + + +def test_refetched_token_list_candidates_need_onchain_confirmation(monkeypatch): + """Кандидат из token_list без on-chain подтверждения не принимается (фантом $1017 на arb).""" + class Inflated(FakeRabbyClient): + def get_total_balance(self, address, is_core=True): + return {"total_usd_value": 1405.0, + "chain_list": [{"id": "eth", "usd_value": 371.32}, {"id": "arb", "usd_value": 1034.0}]} + + def get_token_list(self, address, chain_id, is_all=False): + if chain_id == "arb": + return [{"id": "0xusdc", "symbol": "USDC", "chain": "arb", "amount": 1017.0, "price": 1.0, + "is_verified": True, "is_core": True, "is_scam": False}] + return [] + + def onchain(token, address, chain_map, proxy=None): + return 0.0 if token.get("id") == "0xusdc" else float(token["amount"]) + + _patch(monkeypatch, Inflated, onchain=onchain) + snap = checker._fetch_snapshot("0xabc", "http://proxy") + assert round(snap["total_usd"], 2) == 388.68 + assert snap["aggregate_agrees"] is False + assert all(t["symbol"] != "USDC" or t["chain"] != "arb" for t in snap["tokens_data"]) + + +def test_aggregate_403_does_not_fail_snapshot(monkeypatch): + """403 на total_balance → выборка без агрегата, итог из компонентов.""" + class Banned(FakeRabbyClient): + def get_total_balance(self, address, is_core=True): + raise RuntimeError("HTTP Error 403: Forbidden") + + _patch(monkeypatch, Banned) + snap = checker._fetch_snapshot("0xabc", "http://proxy") + assert snap["status"] == "OK" + assert snap["aggregate_usd"] is None + assert round(snap["total_usd"], 2) == 388.68 + assert any("недоступен" in n for n in snap["notes"]) + + +def test_phantom_appchain_positions_from_rabby_ignored(monkeypatch): + """Chainless Hyperliquid-позиция из Rabby ($18k) не входит в итог, если нативный API даёт 0.""" + class PhantomHL(FakeRabbyClient): + def get_complex_app_list(self, address): + return super().get_complex_app_list(address) + [{ + "id": "hyperliquid", "name": "Hyperliquid", + "portfolio_item_list": [{ + "name": "Deposit", "detail_types": ["common"], + "base": {"app_id": "hyperliquid", "user_addr": address}, + "stats": {"net_usd_value": 18668.08, "asset_usd_value": 18668.08}, + "asset_token_list": [{"symbol": "HYPE", "amount": 400, "price": 46.67, + "is_verified": True, "is_scam": False}], + "detail": {"supply_token_list": [{"symbol": "HYPE", "amount": 400}]}, + }], + }] + + _patch(monkeypatch, PhantomHL) + snap = checker._fetch_snapshot("0xabc", "http://proxy") + assert round(snap["total_usd"], 2) == 388.68 + assert all("Hyperliquid" not in p["name"] for p in snap["protocols_data"]) + assert any("hyperliquid" in n and "отклонены" in n for n in snap["notes"]) + + +def test_native_appchain_positions_counted(monkeypatch): + """Позиции, подтверждённые нативным API Hyperliquid, входят в итог.""" + def hl(address, proxy): + return {"hyperliquid": {"total_usd": 100.0, "details": [ + {"type": "Perpetuals", "symbol": "USDC", "amount": 100.0, "value": 100.0}]}, + "lighter": {"total_usd": 0.0, "details": []}, + "polymarket": {"total_usd": 0.0, "details": []}} + + _patch(monkeypatch, appchain=hl) + snap = checker._fetch_snapshot("0xabc", "http://proxy") + assert round(snap["total_usd"], 2) == 488.68 + names = [p["name"] for p in snap["protocols_data"]] + assert "Hyperliquid (native API)" in names + # агрегат 388.68 теперь ниже проверенного итога → расхождение отмечено, но итог не режется + assert snap["aggregate_agrees"] is False + + +def test_polymarket_claim_rejected_by_native_api(monkeypatch): + """Polymarket-позиция из Rabby при нативном 0 отклоняется (не unverified, а фантом).""" + class Poly(FakeRabbyClient): + def get_complex_app_list(self, address): + return [{ + "id": "polymarket", "name": "Polymarket", + "portfolio_item_list": [{ + "name": "Prediction", "stats": {"net_usd_value": 857.05}, + "asset_token_list": [{"symbol": "USDC", "amount": 857.05, "price": 1.0}], + "detail": {}, + }], + }] + + _patch(monkeypatch, Poly) + snap = checker._fetch_snapshot("0xabc", "http://proxy") + assert round(snap["total_usd"], 2) == 380.0 + assert snap["unverified_usd"] == 0.0 + assert any("polymarket" in n and "отклонены" in n for n in snap["notes"]) + + +def test_unknown_appchain_app_excluded_and_noted(monkeypatch): + """App-chain позиция без нативной проверки (opinion) — в unverified_usd, не в итоге.""" + class Poly(FakeRabbyClient): + def get_complex_app_list(self, address): + return [{ + "id": "opinion", "name": "Opinion", + "portfolio_item_list": [{ + "name": "Prediction", "stats": {"net_usd_value": 55.0}, + "asset_token_list": [{"symbol": "USDC", "amount": 55, "price": 1.0}], + "detail": {}, + }], + }] + + _patch(monkeypatch, Poly) + snap = checker._fetch_snapshot("0xabc", "http://proxy") + assert round(snap["total_usd"], 2) == 380.0 + assert snap["unverified_usd"] == 55.0 + assert any("opinion" in n for n in snap["notes"]) + + +def test_appchain_api_failure_fails_snapshot(monkeypatch): + """Недоступность нативного API → исключение (fail-closed), а не молчаливый ноль.""" + def broken(address, proxy): + raise RuntimeError("Hyperliquid недоступен") + + _patch(monkeypatch, appchain=broken) + try: + checker._fetch_snapshot("0xabc", "http://proxy") + except RuntimeError: + return + raise AssertionError("ожидалось исключение") def test_scam_and_non_core_tokens_filtered(monkeypatch): @@ -113,12 +354,12 @@ def test_zero_value_chains_skipped(monkeypatch): def test_protocols_mapped_with_chain_from_token(monkeypatch): - """DeFi-протокол попадает в protocols_data, chain берётся из токена позиции.""" + """EVM DeFi-протокол попадает в protocols_data, chain берётся из токена позиции.""" _patch(monkeypatch) snap = checker._fetch_snapshot("0xabc", "http://proxy") assert len(snap["protocols_data"]) == 1 proto = snap["protocols_data"][0] - assert proto["name"] == "Hyperliquid" + assert proto["name"] == "GMX" assert proto["chain"] == "arb" assert proto["value"] == 8.68 @@ -139,7 +380,8 @@ def test_result_dict_schema_matches_debank(monkeypatch): required = { "address", "total_usd", "tokens_usd", "protocols_usd", "nft_usd", "tokens", "chains", "top_tokens", "tokens_data", "protocols_data", - "nft_data", "proxy", "status", "error", + "nft_data", "proxy", "status", "error", "aggregate_usd", "aggregate_agrees", + "unverified_usd", "notes", } assert required <= set(snap.keys()) assert snap["status"] == "OK" @@ -160,7 +402,7 @@ def get_complex_app_list(self, address): def get_collection_list(self, address, is_all=True): return [] - monkeypatch.setattr(checker, "RabbyClient", EmptyClient) + _patch(monkeypatch, EmptyClient) snap = checker._fetch_snapshot("0xabc", "http://proxy") assert snap["total_usd"] == 0.0 assert snap["tokens_data"] == [] @@ -173,14 +415,14 @@ class CacheBrokenClient(FakeRabbyClient): def get_cache_token_list(self, address): raise RuntimeError("HTTP Error 429: ") - monkeypatch.setattr(checker, "RabbyClient", CacheBrokenClient) + _patch(monkeypatch, CacheBrokenClient) snap = checker._fetch_snapshot("0xabc", "http://proxy") symbols = {t["symbol"] for t in snap["tokens_data"]} assert symbols == {"USDC", "ETH"} def test_wallet_tokens_fetched_by_single_cache_request(monkeypatch): - """Основной путь: токены всех сетей берутся из cache_token_list.""" + """Основной путь: токены всех сетей берутся из cache_token_list, без по-сетевых запросов.""" calls = {"cache": 0, "per_chain": 0} class CountingClient(FakeRabbyClient): @@ -192,7 +434,238 @@ def get_token_list(self, address, chain_id, is_all=False): calls["per_chain"] += 1 return super().get_token_list(address, chain_id, is_all) - monkeypatch.setattr(checker, "RabbyClient", CountingClient) + _patch(monkeypatch, CountingClient) snap = checker._fetch_snapshot("0xabc", "http://proxy") assert calls == {"cache": 1, "per_chain": 0} assert len(snap["tokens_data"]) == 2 + + +# ---------------------------------------------------------------- check_wallet + +class _PM: + def __init__(self, proxies=("http://p1", "http://p2", "http://p3")): + self._p = list(proxies) + self.i = 0 + + def get_proxy(self): + p = self._p[self.i % len(self._p)] + self.i += 1 + return p + + def report_timeout(self, proxy): + pass + + def report_rate_limited(self, proxy): + pass + + def report_dead(self, proxy): + pass + + +def test_check_wallet_requires_two_agreeing_snapshots(monkeypatch): + """Баланс принят (OK) после двух согласованных выборок.""" + _patch(monkeypatch) + monkeypatch.setattr(checker, "CORROBORATION_MIN_AGREE", 2) + monkeypatch.setattr(checker, "RAW_LOG_ENABLED", False) + res = checker.check_wallet("0xabc", _PM()) + assert res["status"] == "OK" + assert res["snapshots"] == 2 + assert round(res["total_usd"], 2) == 388.68 + + +def test_check_wallet_unverified_when_snapshots_disagree(monkeypatch): + """Каждая выборка даёт разную EVM-позицию (фантом complex_app_list) → UNVERIFIED + с консервативным значением.""" + counter = itertools.count(1) + + class Drift(FakeRabbyClient): + def get_complex_app_list(self, address): + n = next(counter) + return [{"id": "gmx", "name": "GMX", "portfolio_item_list": [{ + "name": "Rewards", "stats": {"net_usd_value": 100.0 * n}, + "asset_token_list": [{"symbol": "USDC", "amount": 100.0 * n, "price": 1.0, "chain": "arb"}], + "detail": {"supply_token_list": [{"symbol": "USDC", "amount": 100.0 * n, "chain": "arb"}]}, + }]}] + + _patch(monkeypatch, Drift) + monkeypatch.setattr(checker, "CORROBORATION_MIN_AGREE", 2) + monkeypatch.setattr(checker, "CORROBORATION_MAX_FETCHES", 3) + monkeypatch.setattr(checker, "RAW_LOG_ENABLED", False) + res = checker.check_wallet("0xabc", _PM()) + assert res["status"] == "UNVERIFIED" + assert res["snapshots"] == 3 + assert round(res["total_usd"], 2) == 480.0 # минимальная выборка: 380 токенов + 100 + assert "не подтверждён" in res["error"] + + +def test_check_wallet_retries_transient_errors(monkeypatch): + """Сетевая ошибка первой выборки → повтор; итог OK.""" + calls = {"n": 0} + + class Flaky(FakeRabbyClient): + def get_total_balance(self, address, is_core=True): + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("Connection reset by peer") + return super().get_total_balance(address, is_core) + + _patch(monkeypatch, Flaky) + monkeypatch.setattr(checker, "CORROBORATION_MIN_AGREE", 2) + monkeypatch.setattr(checker, "RETRY_BACKOFF_SEC", 0.0) + monkeypatch.setattr(checker, "RAW_LOG_ENABLED", False) + res = checker.check_wallet("0xabc", _PM()) + assert res["status"] == "OK" + assert calls["n"] == 3 + + +def test_check_wallet_no_proxy_without_direct_is_error(monkeypatch): + _patch(monkeypatch) + monkeypatch.setattr(checker, "direct_allowed", lambda: False) + res = checker.check_wallet("0xabc", _PM(proxies=(None,))) + assert res["status"] == "ERROR" + + +# ---------------------------------------------------------------- скорость без потери проверок + +class _RecordingPM(_PM): + def __init__(self, proxies=("http://p1", "http://p2", "http://p3", "http://p4")): + super().__init__(proxies) + self.dead = [] + + def report_dead(self, proxy): + self.dead.append(proxy) + + +def test_check_wallet_hedges_stuck_snapshots(monkeypatch): + """Обе первые выборки зависли → хедж через SNAPSHOT_HEDGE_SEC даёт результат, не дожидаясь их.""" + counter = itertools.count(1) + + class Stuck(FakeRabbyClient): + def get_complex_app_list(self, address): + if next(counter) <= 2: + time.sleep(3) + return super().get_complex_app_list(address) + + _patch(monkeypatch, Stuck) + monkeypatch.setattr(checker, "SNAPSHOT_HEDGE_SEC", 0.1) + monkeypatch.setattr(checker, "RAW_LOG_ENABLED", False) + started = time.perf_counter() + res = checker.check_wallet("0xabc", _PM()) + assert res["status"] == "OK" + assert round(res["total_usd"], 2) == 388.68 + assert time.perf_counter() - started < 2.0 + + +def test_native_api_called_once_per_wallet(monkeypatch): + """Нативные app-chain API запрашиваются один раз на кошелёк, а не в каждой выборке.""" + calls = [] + + def appchain(address, proxy): + calls.append(proxy) + return _no_appchain(address, proxy) + + _patch(monkeypatch, appchain=appchain) + monkeypatch.setattr(checker, "RAW_LOG_ENABLED", False) + res = checker.check_wallet("0xabc", _PM()) + assert res["status"] == "OK" and res["snapshots"] == 2 + assert len(calls) == 1 + + +def test_nfts_loaded_in_background(monkeypatch): + """Баланс возвращается сразу, NFT дописываются в результат фоновой загрузкой.""" + _patch(monkeypatch) + monkeypatch.setattr(checker, "RAW_LOG_ENABLED", False) + res = checker.check_wallet("0xabc", _PM()) + assert checker.wait_nfts(timeout=5) == 0 + assert [n["name"] for n in res["nft_data"]] == ["GMCards"] + # chains — только сети с балансом: не меняется от того, догрузились ли NFT + assert "soneium" not in res["chains"] and "eth" in res["chains"] + + +def test_dead_proxy_is_reported(monkeypatch): + """Прокси, отвергший подключение (407), исключается из ротации.""" + from debank_checker.api.http import ProxyDead + + class DeadFirst(FakeRabbyClient): + def get_cache_token_list(self, address): + if self.proxy == "http://p1": + raise ProxyDead(self.proxy, "CONNECT tunnel failed, response 407") + return super().get_cache_token_list(address) + + _patch(monkeypatch, DeadFirst) + monkeypatch.setattr(checker, "RAW_LOG_ENABLED", False) + monkeypatch.setattr(checker, "RETRY_PROXY_BACKOFF_SEC", 0.0) + pm = _RecordingPM() + res = checker.check_wallet("0xabc", pm) + assert res["status"] == "OK" + assert pm.dead == ["http://p1"] + + +# ---------------------------------------------------------------- подмена списка токенов Rabby + +_TRUE_AMOUNTS = {"USDC": 371.32, "ETH": 0.004} # что реально лежит на кошельке (on-chain) + + +def _onchain_truth(token, address, chain_map, proxy=None): + return _TRUE_AMOUNTS.get(token["symbol"], 0.0) + + +_FOREIGN_LIST = [ # чужой кошелёк: наших USDC нет, есть чужой USDG и «чужое» количество ETH + {"symbol": "USDG", "chain": "eth", "amount": 2428.78, "price": 1.0, "is_core": True}, + {"symbol": "ETH", "chain": "arb", "amount": 0.05, "price": 2170.0, "is_core": True}, +] +_OUR_LIST_WITHOUT_USDC = [ # список без нашего крупного токена, остальное честно + {"symbol": "ETH", "chain": "arb", "amount": 0.004, "price": 2170.0, "is_core": True}, +] + + +def _client_with_lists(lists, aggregate=True): + counter = itertools.count() + + class Swapping(FakeRabbyClient): + def get_total_balance(self, address, is_core=True): + if not aggregate: + raise RuntimeError("HTTP Error 403: Forbidden") + return super().get_total_balance(address, is_core) + + def get_cache_token_list(self, address): + n = next(counter) + return lists[n] if n < len(lists) else lists[-1] + + return Swapping + + +def test_foreign_token_list_pair_is_not_accepted(monkeypatch): + """Инцидент 0x9Cd7…: две выборки с одинаковым ЧУЖИМ списком сошлись на пыли ($1.44). + Такие выборки заражены (on-chain сняла >10%) и в согласии не участвуют.""" + good = FakeRabbyClient(None).get_cache_token_list("0xabc") + _patch(monkeypatch, _client_with_lists([_FOREIGN_LIST, _FOREIGN_LIST, _FOREIGN_LIST, good, good]), + onchain=_onchain_truth) + monkeypatch.setattr(checker, "RAW_LOG_ENABLED", False) + res = checker.check_wallet("0xabc", _PM()) + assert res["status"] == "OK" + assert round(res["total_usd"], 2) == 388.68 + assert any("чужим/неполным" in n for n in res["notes"]) + assert not any(k.startswith("_") for k in res) + + +def test_list_missing_confirmed_token_is_not_accepted(monkeypatch): + """Выборка без токена, подтверждённого on-chain в другой выборке, — неполная + (агрегат Rabby недоступен, восстановить пропажу через сверку по сетям нельзя).""" + good = FakeRabbyClient(None).get_cache_token_list("0xabc") + _patch(monkeypatch, _client_with_lists([good, _OUR_LIST_WITHOUT_USDC, _OUR_LIST_WITHOUT_USDC, good], + aggregate=False), + onchain=_onchain_truth) + monkeypatch.setattr(checker, "RAW_LOG_ENABLED", False) + monkeypatch.setattr(checker, "SNAPSHOT_HEDGE_SEC", 60) + res = checker.check_wallet("0xabc", _PM()) + assert res["status"] == "OK" + assert round(res["total_usd"], 2) == 388.68 + + +def test_only_foreign_lists_end_unverified(monkeypatch): + """Rabby всё время отдаёт чужой список → UNVERIFIED, а не «подтверждённая» пыль.""" + _patch(monkeypatch, _client_with_lists([_FOREIGN_LIST]), onchain=_onchain_truth) + monkeypatch.setattr(checker, "RAW_LOG_ENABLED", False) + res = checker.check_wallet("0xabc", _PM()) + assert res["status"] == "UNVERIFIED" diff --git a/tools/build_rpc_registry.py b/tools/build_rpc_registry.py new file mode 100644 index 0000000..c99c110 --- /dev/null +++ b/tools/build_rpc_registry.py @@ -0,0 +1,171 @@ +"""Собирает и проверяет живьём публичные RPC для всех сетей Rabby. + + python tools/build_rpc_registry.py + +Источники: chainid.network/chains.json, шаблоны крупных публичных провайдеров +(publicnode, drpc, 1rpc, blastapi, meowrpc, tenderly и др.), ручной список. +Каждый URL проверяется: eth_chainId должен совпасть, eth_getBalance — ответить. +Результат: debank_checker/data/rpc_registry.json — {chain_id: [url, ...]} в +порядке возрастания задержки. Файл включается в поставку; при запуске он +объединяется с живым chainid.network и пользовательским rpc.txt. +""" +from __future__ import annotations + +import json +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import curl_cffi.requests as r + +ROOT = Path(__file__).resolve().parent.parent + +# Проверенные вручную RPC для сетей, где лежит основная часть балансов — +# идут первыми в списке кандидатов. +PREFERRED_RPC: dict[int, list[str]] = { + 1: ["https://ethereum-rpc.publicnode.com", "https://cloudflare-eth.com", "https://eth.llamarpc.com"], + 10: ["https://optimism-rpc.publicnode.com", "https://mainnet.optimism.io"], + 56: ["https://bsc-rpc.publicnode.com", "https://bsc-dataseed1.bnbchain.org", "https://bsc-dataseed2.bnbchain.org"], + 137: ["https://polygon-bor-rpc.publicnode.com", "https://polygon-rpc.com"], + 8453: ["https://base-rpc.publicnode.com", "https://mainnet.base.org"], + 42161: ["https://arbitrum-one-rpc.publicnode.com", "https://arb1.arbitrum.io/rpc"], + 43114: ["https://avalanche-c-chain-rpc.publicnode.com", "https://api.avax.network/ext/bc/C/rpc"], + 1868: ["https://rpc.soneium.org", "https://soneium.drpc.org"], + 59144: ["https://linea-rpc.publicnode.com", "https://rpc.linea.build"], + 534352: ["https://scroll-rpc.publicnode.com", "https://rpc.scroll.io"], + 324: ["https://mainnet.era.zksync.io"], + 5000: ["https://mantle-rpc.publicnode.com", "https://rpc.mantle.xyz"], + 100: ["https://gnosis-rpc.publicnode.com", "https://rpc.gnosischain.com"], + 130: ["https://unichain-rpc.publicnode.com", "https://mainnet.unichain.org"], + 57073: ["https://rpc-gel.inkonchain.com", "https://rpc-qnd.inkonchain.com"], + 1135: ["https://rpc.api.lisk.com"], + 34443: ["https://mainnet.mode.network", "https://mode.drpc.org"], + 480: ["https://worldchain-mainnet.g.alchemy.com/public", "https://480.rpc.thirdweb.com"], + 48900: ["https://mainnet.zircuit.com"], + 7777777: ["https://rpc.zora.energy"], + 81457: ["https://rpc.blast.io"], + 167000: ["https://rpc.mainnet.taiko.xyz"], + 204: ["https://opbnb-rpc.publicnode.com", "https://opbnb-mainnet-rpc.bnbchain.org"], + 146: ["https://rpc.soniclabs.com"], + 42220: ["https://forno.celo.org"], + 1116: ["https://rpc.coredao.org"], + 8217: ["https://public-en.node.kaia.io"], + 2222: ["https://evm.kava.io"], + 169: ["https://pacific-rpc.manta.network/http"], + 1625: ["https://rpc.gravity.xyz"], + 20240603: ["https://rpc.dbkchain.io"], + 4663: ["https://rpc.mainnet.chain.robinhood.com", "https://robinhood-rpc.publicnode.com"], + 143: ["https://rpc.monad.xyz"], + 999: ["https://rpc.hyperliquid.xyz/evm"], +} + +RABBY_CHAINS = json.load(open("/tmp/rabby_chains.json")) if Path("/tmp/rabby_chains.json").exists() else None +TEST_ADDR = "0x0000000000000000000000000000000000000001" + +# слаги провайдеров по chain_id (только там, где известны) +SLUGS = { + 1: ["ethereum", "eth", "mainnet"], 10: ["optimism", "op"], 56: ["bsc", "bnb"], 100: ["gnosis", "xdai"], + 137: ["polygon", "matic"], 8453: ["base"], 42161: ["arbitrum", "arbitrum-one", "arb"], 43114: ["avalanche", "avax"], + 59144: ["linea"], 534352: ["scroll"], 324: ["zksync", "zksync-era"], 5000: ["mantle"], 130: ["unichain"], + 57073: ["ink"], 1135: ["lisk"], 34443: ["mode"], 480: ["worldchain", "world-chain"], 48900: ["zircuit"], + 7777777: ["zora"], 81457: ["blast"], 167000: ["taiko"], 204: ["opbnb"], 146: ["sonic"], 42220: ["celo"], + 1116: ["core"], 8217: ["kaia", "klaytn"], 2222: ["kava"], 169: ["manta", "manta-pacific"], 1625: ["gravity"], + 1868: ["soneium"], 252: ["fraxtal"], 33139: ["apechain"], 1088: ["metis"], 2818: ["morph"], 80094: ["berachain", "bera"], + 1329: ["sei"], 7000: ["zetachain", "zeta"], 1030: ["conflux"], 7560: ["cyber"], 143: ["monad"], 2020: ["ronin"], + 13371: ["immutable", "immutable-zkevm"], 122: ["fuse"], 4200: ["merlin"], 30: ["rootstock", "rsk"], 50104: ["sophon"], + 200901: ["bitlayer"], 60808: ["bob"], 25: ["cronos"], 999: ["hyperliquid", "hyperevm"], 88888: ["chiliz"], + 2741: ["abstract"], 14: ["flare"], 196: ["xlayer", "x-layer"], 1514: ["story"], 232: ["lens"], 747474: ["katana"], + 98866: ["plume"], 43111: ["hemi"], 50: ["xdc"], 1111: ["wemix"], 9745: ["plasma"], 42793: ["etherlink"], + 16661: ["0g"], 223: ["b2", "bsquared"], 4663: ["robinhood"], 20240603: ["dbk", "dbkchain"], 988: ["stable"], + 4326: ["megaeth"], 4217: ["tempo"], 2366: ["kite"], 5042: ["arc"], 4114: ["citrea"], +} +TEMPLATES = [ + "https://{s}-rpc.publicnode.com", "https://{s}.drpc.org", "https://1rpc.io/{s}", "https://{s}-mainnet.public.blastapi.io", + "https://{s}.meowrpc.com", "https://{s}.gateway.tenderly.co", "https://rpc.{s}.gateway.fm", "https://{s}.rpc.thirdweb.com", + "https://{s}.llamarpc.com", "https://{s}.blockpi.network/v1/rpc/public", "https://rpc-{s}.publicnode.com", + "https://{s}-mainnet.g.alchemy.com/v2/demo", "https://{s}.api.onfinality.io/public", "https://{s}.rpc.subquery.network/public", + "https://endpoints.omniatech.io/v1/{s}/mainnet/public", "https://rpc.ankr.com/{s}", +] +EXTRA = { + 2741: ["https://api.mainnet.abs.xyz"], 999: ["https://rpc.hyperliquid.xyz/evm", "https://rpc.hyperlend.finance", "https://hyperliquid.drpc.org"], + 9745: ["https://rpc.plasma.to"], 4326: ["https://mainnet.megaeth.com/rpc", "https://rpc.megaeth.com"], 988: ["https://rpc.stable.xyz"], + 4217: ["https://rpc.tempo.xyz", "https://rpc.mainnet.tempo.xyz"], 5042: ["https://rpc.arc.network", "https://rpc.mainnet.arc.network"], + 2366: ["https://rpc.gokite.ai", "https://rpc.kite.ai"], 4114: ["https://rpc.mainnet.citrea.xyz", "https://rpc.citrea.xyz"], + 16661: ["https://evmrpc.0g.ai", "https://evmrpc-mainnet.0g.ai"], 1514: ["https://mainnet.storyrpc.io"], 232: ["https://rpc.lens.xyz"], + 747474: ["https://rpc.katana.network", "https://rpc.katanarpc.com"], 98866: ["https://rpc.plume.org", "https://phoenix-rpc.plumenetwork.xyz"], + 43111: ["https://rpc.hemi.network/rpc"], 42793: ["https://node.mainnet.etherlink.com"], 223: ["https://rpc.bsquared.network", "https://mainnet.b2-rpc.com"], + 4663: ["https://rpc.mainnet.chain.robinhood.com", "https://robinhood-rpc.publicnode.com"], 20240603: ["https://rpc.dbkchain.io", "https://rpc.mainnet.dbkchain.io"], + 2818: ["https://rpc.morphl2.io", "https://rpc-quicknode.morphl2.io"], 80094: ["https://rpc.berachain.com"], 1329: ["https://evm-rpc.sei-apis.com"], + 7000: ["https://zetachain-evm.blockpi.network/v1/rpc/public", "https://zetachain-mainnet.g.allthatnode.com/archive/evm"], + 1030: ["https://evm.confluxrpc.com"], 7560: ["https://cyber.alt.technology", "https://rpc.cyber.co"], 2020: ["https://api.roninchain.com/rpc"], + 13371: ["https://rpc.immutable.com"], 122: ["https://rpc.fuse.io"], 4200: ["https://rpc.merlinchain.io"], 30: ["https://public-node.rsk.co"], + 50104: ["https://rpc.sophon.xyz"], 200901: ["https://rpc.bitlayer.org", "https://rpc.bitlayer-rpc.com"], 60808: ["https://rpc.gobob.xyz"], + 25: ["https://evm.cronos.org", "https://cronos-evm-rpc.publicnode.com"], 88888: ["https://rpc.ankr.com/chiliz", "https://chiliz.publicnode.com"], + 14: ["https://flare-api.flare.network/ext/C/rpc"], 196: ["https://rpc.xlayer.tech", "https://xlayerrpc.okx.com"], 50: ["https://erpc.xinfin.network", "https://rpc.xdc.org"], + 1111: ["https://api.wemix.com"], 33139: ["https://rpc.apechain.com", "https://apechain.calderachain.xyz/http"], 1088: ["https://andromeda.metis.io/?owner=1088"], + 252: ["https://rpc.frax.com"], 143: ["https://rpc.monad.xyz"], +} + + +def load_chainid() -> dict[int, list[str]]: + data = r.get("https://chainid.network/chains.json", timeout=30).json() + out: dict[int, list[str]] = {} + for c in data: + urls = [u for u in c.get("rpc", []) if isinstance(u, str) and u.startswith("https://") and "${" not in u] + if urls: + out[int(c["chainId"])] = urls + return out + + +def probe(chain_id: int, url: str) -> tuple[str, float] | None: + t = time.time() + try: + resp = r.post(url, json={"jsonrpc": "2.0", "id": 1, "method": "eth_chainId", "params": []}, timeout=8) + if resp.status_code != 200: + return None + cid = int(resp.json()["result"], 16) + if cid != chain_id: + return None + resp = r.post(url, json={"jsonrpc": "2.0", "id": 2, "method": "eth_getBalance", "params": [TEST_ADDR, "latest"]}, timeout=8) + if resp.status_code != 200 or "result" not in resp.json(): + return None + return url, time.time() - t + except Exception: + return None + + +def main() -> None: + chains = RABBY_CHAINS or [] + ids = {int(c["community_id"]): c["id"] for c in chains} if chains else {cid: str(cid) for cid in PREFERRED_RPC} + registry = load_chainid() + cand: dict[int, list[str]] = {} + for cid in ids: + urls: list[str] = [] + for u in PREFERRED_RPC.get(cid, []) + EXTRA.get(cid, []) + registry.get(cid, []): + if u not in urls: + urls.append(u) + for slug in SLUGS.get(cid, []): + for t in TEMPLATES: + u = t.format(s=slug) + if u not in urls: + urls.append(u) + cand[cid] = urls + total = sum(len(v) for v in cand.values()) + print(f"chains={len(cand)} candidates={total}") + jobs = [(cid, u) for cid, us in cand.items() for u in us] + with ThreadPoolExecutor(max_workers=48) as ex: + res = list(ex.map(lambda j: (j[0], probe(*j)), jobs)) + good: dict[int, list[tuple[str, float]]] = {} + for cid, ok in res: + if ok: + good.setdefault(cid, []).append(ok) + out = {str(cid): [u for u, _ in sorted(v, key=lambda x: x[1])] for cid, v in good.items()} + (ROOT / "debank_checker" / "data").mkdir(exist_ok=True) + json.dump(out, open(ROOT / "debank_checker" / "data" / "rpc_registry.json", "w"), indent=1) + for cid, slug in sorted(ids.items(), key=lambda kv: len(good.get(kv[0], []))): + print(f"{slug:9} {cid:>9} working={len(good.get(cid, [])):3} / {len(cand[cid])}") + print("chains without RPC:", [ids[c] for c in ids if c not in good]) + + +if __name__ == "__main__": + main()