diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b082b54..6cb2d9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,11 @@ jobs: - name: Import smoke check run: | - python -c "import debank_checker.api.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.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: | + python -m pytest tests/ -q - name: Bytecode compile run: | diff --git a/.gitignore b/.gitignore index a439fb5..2cb375e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ env/ # Output Results/ debug_log.txt +logs.txt # Local inputs (do not commit) wallets.txt diff --git a/README.md b/README.md index 6257249..7e80ff5 100644 --- a/README.md +++ b/README.md @@ -112,30 +112,35 @@ python main.py --- -## Источник балансов: Rabby (по умолчанию) / DeBank - -Балансы можно получать через **Rabby API** (`api.rabby.io`) или через старый -**DeBank API**. Переключатель — `BALANCE_SOURCE` в `debank_checker/config.py` -(`"rabby"` по умолчанию, `"debank"` для отката). - -**Почему Rabby.** DeBank-ветка считала итог кошелька ВРУЧНУЮ -(`total = tokens_usd + protocols_usd` из двух отдельных запросов). Под высокой -конкуренцией DeBank изредка отдаёт ответ от **чужого адреса** -(response contamination) — и на кошельке с $15 «появлялись» миллионы. Rabby -отдаёт готовый агрегированный итог одним запросом -(`/v1/user/total_balance?is_core=true`, `total_usd_value` уже включает токены и -DeFi), поэтому итоговая сумма **не зависит** от ручного суммирования потенциально -загрязнённых ответов — корневая причина фантомов устранена. - -Схема подписи запросов у Rabby идентична DeBank (проверено воспроизведением -подписи из HAR веб-клиента байт-в-байт, см. `tests/test_rabby_client.py`); -`is_core=true` оставляет только проверенные (core) токены. Версия веб-клиента -Rabby — в `RABBY_CLIENT_VERSION`; при поломке подписи обнови её из свежего HAR. +## Источник балансов: Rabby API + +Балансы получаются через **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. + +### Заголовки должны точно повторять веб-клиент + +Анти-бот 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 имеет смысл поднять -до `2`. Параметры — в `debank_checker/config.py` (`CORROBORATION_*`). +Сохранён механизм **корроборации**: баланс принимается, если подтверждён +`CORROBORATION_MIN_AGREE` независимыми выборками, сошедшимися по сумме. +Для Rabby итог авторитетен, поэтому по умолчанию `MIN_AGREE=1` (лишних +запросов нет), а механика остаётся как страховка. Параметры — в +`debank_checker/config.py` (`CORROBORATION_*`). diff --git a/debank_checker/__init__.py b/debank_checker/__init__.py index 3f14aa0..e59b910 100644 --- a/debank_checker/__init__.py +++ b/debank_checker/__init__.py @@ -1,5 +1,5 @@ """ -DeBank Balance Checker — массовая проверка балансов кошельков через DeBank API. +DeBank Checker — массовая проверка балансов кошельков через Rabby API. """ __version__ = "1.0.3" diff --git a/debank_checker/api/__init__.py b/debank_checker/api/__init__.py index d27bfa5..27437c3 100644 --- a/debank_checker/api/__init__.py +++ b/debank_checker/api/__init__.py @@ -1,5 +1,5 @@ -"""API: DeBank клиент.""" +"""API: клиент Rabby (api.rabby.io).""" -from debank_checker.api.client import DeBankClient +from debank_checker.api.rabby_client import RabbyClient -__all__ = ["DeBankClient"] +__all__ = ["RabbyClient"] diff --git a/debank_checker/api/client.py b/debank_checker/api/client.py deleted file mode 100644 index a725c04..0000000 --- a/debank_checker/api/client.py +++ /dev/null @@ -1,182 +0,0 @@ -""" -DeBank API Client -Подпись HMAC-SHA256, curl_cffi (impersonate chrome). -Прокси обязателен. -""" - -import hashlib -import hmac as hmac_lib -import json -import random -import time -import uuid -from typing import Optional - -import curl_cffi.requests as cffi_requests - -from debank_checker.config import ( - API_KEY_INIT, - NFT_POLL_INTERVAL, - NFT_POLL_MAX_WAIT, - NFT_REQUEST_TIMEOUT, - REQUEST_TIMEOUT, -) - -API_BASE = "https://api.debank.com" -NONCE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz" -NONCE_LENGTH = 40 - - -def sort_params(params: dict) -> str: - if not params: - return "" - return "&".join(f"{k}={v}" for k, v in sorted(params.items())) - - -def sha256_hex(text: str) -> str: - return hashlib.sha256(text.encode("utf-8")).hexdigest() - - -def hmac_sha256(key_str: str, msg_str: str) -> str: - return hmac_lib.new( - key_str.encode("utf-8"), - msg_str.encode("utf-8"), - hashlib.sha256, - ).hexdigest() - - -def generate_nonce() -> str: - return "n_" + "".join(random.choices(NONCE_ALPHABET, k=NONCE_LENGTH)) - - -def sign_request( - params: dict, - method: str, - path: str, - nonce: str | None = None, - ts: int | None = None, - version: str = "v2", -) -> dict: - ts = ts or int(time.time()) - nonce = nonce or generate_nonce() - prefix = "debank-web\n" if version == "v2.1" else "debank-api\n" - sorted_p = sort_params(params) - K = sha256_hex(f"{prefix}{nonce}\n{ts}") - M = sha256_hex(f"{method.upper()}\n{path}\n{sorted_p}") - signature = hmac_sha256(K, M) - return {"signature": signature, "nonce": nonce, "ts": ts, "version": version} - - -class DeBankClient: - """Клиент DeBank API. Прокси обязателен.""" - - def __init__(self, proxy: str, impersonate: str = "chrome124"): - if not proxy: - raise ValueError("Прокси обязателен для DeBank API") - self._api_key = API_KEY_INIT - self._init_ts = int(time.time()) - self._random_at = self._init_ts - self._random_id = uuid.uuid4().hex - self._impersonate = impersonate - proxies = {"https": proxy, "http": proxy} - self._session = cffi_requests.Session( - impersonate=impersonate, - proxies=proxies, - ) - - def _build_headers(self, params: dict, method: str, path: str) -> dict: - sign = sign_request(params, method, path) - account = json.dumps( - { - "random_at": self._random_at, - "random_id": self._random_id, - "user_addr": None, - "connected_addr": None, - }, - separators=(",", ":"), - ) - return { - "Referer": "https://debank.com/", - "Origin": "https://debank.com", - "X-API-Key": self._api_key, - "X-API-Time": str(self._init_ts), - "x-api-ts": str(sign["ts"]), - "x-api-nonce": sign["nonce"], - "x-api-ver": sign["version"], - "x-api-sign": sign["signature"], - "source": "web", - "account": account, - } - - def _get(self, path: str, params: dict | None = None, timeout: float | None = None) -> dict: - 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, - ) - resp.raise_for_status() - - new_key = resp.headers.get("x-set-api-key") - if new_key: - self._api_key = new_key - - data = resp.json() - if isinstance(data, dict) and "data" in data: - return data["data"] - return data - - def get_total_balance_cache(self, address: str) -> list: - """Кэшированный список токенов по всем сетям.""" - result = self._get("/token/cache_balance_list", {"user_addr": address}) - return result if isinstance(result, list) else [] - - def get_portfolio(self, address: str) -> list: - """DeFi протоколы с позициями.""" - result = self._get("/portfolio/project_list", {"user_addr": address}) - return result if isinstance(result, list) else [] - - def get_nft_used_chains(self, address: str) -> list: - """Сети, в которых у пользователя есть NFT.""" - result = self._get("/nft/used_chains", {"user_addr": address}) - if isinstance(result, list): - return [ - c.get("id") if isinstance(c, dict) else str(c) - for c in result - if isinstance(c, (dict, str)) - ] - return [] - - def get_nft_collection_list(self, address: str, chain: str) -> list: - """ - NFT-коллекции по адресу и сети. - API может вернуть async job (pending) — тогда polling с retry. - Результат: list из result["data"] или result (если list). - """ - params = {"user_addr": address, "chain": chain} - deadline = time.time() + NFT_POLL_MAX_WAIT - - def _extract_list(val) -> list: - if isinstance(val, list): - return val - if isinstance(val, dict) and "data" in val: - d = val["data"] - return d if isinstance(d, list) else [] - return [] - - while True: - result = self._get("/nft/collection_list", params, timeout=NFT_REQUEST_TIMEOUT) - if isinstance(result, list): - return result - if isinstance(result, dict): - job = result.get("job") or {} - status = job.get("status") if job else None - res = result.get("result") - if res is not None: - return _extract_list(res) - if status == "pending" and time.time() < deadline: - time.sleep(NFT_POLL_INTERVAL) - continue - return [] diff --git a/debank_checker/api/rabby_client.py b/debank_checker/api/rabby_client.py index 3afb620..c7da836 100644 --- a/debank_checker/api/rabby_client.py +++ b/debank_checker/api/rabby_client.py @@ -1,25 +1,31 @@ """ Rabby API Client (api.rabby.io). -Схема подписи запросов ИДЕНТИЧНА DeBank (см. api/client.py), меняются только -префикс (`rabby-api\\n`) и набор заголовков идентификации клиента -(`x-client`/`x-version` вместо `source`/`account`). Проверено воспроизведением +Схема подписи запросов: см. sign_request ниже. Проверено воспроизведением подписи из HAR веб-версии Rabby байт-в-байт (65/68 запросов; расхождения — в неиспользуемых эндпоинтах has_new_tx/history_list). -Зачем миграция: DeBank-чекер считал total ВРУЧНУЮ (tokens_usd + protocols_usd -из двух запросов), а под высокой конкуренцией DeBank изредка отдаёт ответ от -чужого адреса — так на кошельке с $15 «появлялись» миллионы. Rabby отдаёт -готовый агрегированный `total_usd_value` одним запросом -(`/v1/user/total_balance?is_core=true`), поэтому итоговая сумма больше не -зависит от ручного суммирования потенциально загрязнённых ответов. - -Прокси обязателен (как и в DeBank-клиенте). +Заголовки идентификации должны ТОЧНО повторять клиент Rabby (сверено с HAR +браузерного расширения — tests/fixtures и свежий HAR расширения): + - подписные заголовки — в нижнем регистре (x-api-key, x-api-time, ...); + - x-api-time — время ВЫДАЧИ текущего API-ключа, а не время запроса + (в HAR оно одинаково во всех запросах сессии и на ~23 млн сек старше + x-api-ts); + - x-api-ver: v2 отправляется (в отличие от урезанной веб-фикстуры); + - браузерные заголовки (accept-language, dnt, priority, sec-fetch-*) + досылаются поверх impersonate-фингерпринта curl_cffi. +Анти-бот API на любое отклонение отвечает фейковым 429 с пустым телом — +именно так душился /v1/user/token_list при верной подписи (проверено на +live-API: старые заголовки → 429, заголовки клиента → 200; расширение +делает ~10 req/s без единого 429). + +Прокси обязателен. """ import hashlib import hmac as hmac_lib import random +import threading import time from typing import Any @@ -27,16 +33,38 @@ from debank_checker.config import ( RABBY_API_KEY_INIT, + RABBY_API_KEY_INIT_TIME, RABBY_CLIENT_VERSION, REQUEST_TIMEOUT, ) API_BASE = "https://api.rabby.io" SIGN_PREFIX = "rabby-api\n" -# Алфавит nonce — как в клиенте Rabby/DeBank (sic: без Y и j). +# Алфавит nonce — как в клиенте Rabby (sic: без Y и j). NONCE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz" NONCE_LENGTH = 40 +# Общий на процесс магазин ключа: снапшоты создают новый RabbyClient на каждую +# попытку, а ротированный сервером ключ терять нельзя — иначе каждый клиент +# снова начнёт с init-ключа и его лимитов. Все клиенты продолжают с последнего +# выданного ключа; время выдачи нового ключа — момент ротации. +_KEY_LOCK = threading.Lock() +_KEY_STATE = {"key": RABBY_API_KEY_INIT, "time": RABBY_API_KEY_INIT_TIME} + + +def _current_key() -> tuple[str, int]: + with _KEY_LOCK: + return _KEY_STATE["key"], _KEY_STATE["time"] + + +def _rotate_key(new_key: str) -> int: + """Обновляет общий ключ; возвращает время выдачи действующего ключа.""" + with _KEY_LOCK: + if new_key and new_key != _KEY_STATE["key"]: + _KEY_STATE["key"] = new_key + _KEY_STATE["time"] = int(time.time()) + return _KEY_STATE["time"] + def sort_params(params: dict) -> str: if not params: @@ -90,8 +118,7 @@ class RabbyClient: def __init__(self, proxy: str, impersonate: str = "chrome124"): if not proxy: raise ValueError("Прокси обязателен для Rabby API") - self._api_key = RABBY_API_KEY_INIT - self._init_ts = int(time.time()) + self._api_key, self._key_time = _current_key() self._impersonate = impersonate proxies = {"https": proxy, "http": proxy} self._session = cffi_requests.Session( @@ -100,11 +127,20 @@ def __init__(self, proxy: str, impersonate: str = "chrome124"): ) def _build_headers(self, params: dict, method: str, path: str) -> dict: + # Состав и кейсинг — строго по HAR расширения Rabby (см. докстринг + # модуля): отклонение карается фейковым 429 с пустым телом. sign = sign_request(params, method, path) return { "accept": "application/json, text/plain, */*", - "X-API-Key": self._api_key, - "X-API-Time": str(self._init_ts), + "accept-language": "ru,ru-RU;q=0.9,en-US;q=0.8,en;q=0.7", + "dnt": "1", + "priority": "u=1, i", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "none", + "sec-fetch-storage-access": "active", + "x-api-key": self._api_key, + "x-api-time": str(self._key_time), "x-api-ts": str(sign["ts"]), "x-api-nonce": sign["nonce"], "x-api-ver": sign["version"], @@ -122,13 +158,16 @@ def _get(self, path: str, params: dict | None = None, timeout: float | None = No headers=headers, timeout=timeout or REQUEST_TIMEOUT, ) - resp.raise_for_status() - # Сервер может ротировать ключ через x-set-api-key (как в DeBank). + # Ротация ключа читается ДО raise_for_status: сервер может выдать + # новый ключ и вместе с ошибкой (429/403) — раньше он терялся. new_key = resp.headers.get("x-set-api-key") - if new_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"] @@ -146,6 +185,17 @@ def get_total_balance(self, address: str, is_core: bool = True) -> dict: result = self._get("/v1/user/total_balance", params) return result if isinstance(result, dict) else {} + def get_cache_token_list(self, address: str) -> list: + """Токены кошелька по ВСЕМ сетям одним запросом (серверный кэш). + + Заменяет десятки запросов token_list (по одному на сеть) — расширение + Rabby само использует этот эндпоинт для быстрой загрузки. Ответ — тот + же формат токенов (amount/price/is_core/is_scam), фильтрация на + стороне чекера. + """ + result = self._get("/v1/user/cache_token_list", {"id": address.lower()}) + return result if isinstance(result, list) else [] + def get_token_list(self, address: str, chain_id: str, is_all: bool = False) -> list: """Список токенов кошелька в конкретной сети. diff --git a/debank_checker/checker.py b/debank_checker/checker.py index b59173e..1078211 100644 --- a/debank_checker/checker.py +++ b/debank_checker/checker.py @@ -1,14 +1,12 @@ -""" -Проверка балансов кошельков: tokens + portfolio (+ NFT заглушка) -""" - -import time -from typing import Any - -from debank_checker.api.client import DeBankClient +""" +Проверка балансов кошельков через Rabby API: tokens + protocols + NFT +""" + +import time +from typing import Any + from debank_checker.api.rabby_client import RabbyClient from debank_checker.config import ( - BALANCE_SOURCE, CORROBORATION_ABS_TOL, CORROBORATION_ENABLED, CORROBORATION_MAX_FETCHES, @@ -19,7 +17,7 @@ RABBY_IS_CORE, RETRY_ATTEMPTS, ) -from debank_checker.proxy.manager import ProxyManager +from debank_checker.proxy.manager import ProxyManager def _mask_proxy(proxy: str | None) -> str: @@ -132,16 +130,15 @@ def _build_tokens_data(tokens: list[dict]) -> list[dict]: ] -def _fetch_snapshot_rabby(address: str, proxy: str) -> dict[str, Any]: +def _fetch_snapshot(address: str, proxy: str) -> dict[str, Any]: """Одна выборка баланса кошелька через Rabby API. - Ключевое отличие от DeBank: total_usd берётся из АВТОРИТЕТНОГО агрегата - /v1/user/total_balance (total_usd_value уже включает и токены, и DeFi — - проверено), а НЕ считается вручную суммой отдельных запросов. Поэтому - итоговая сумма не подвержена «фантомам» от загрязнённых ответов. + Ключевое: total_usd берётся из АВТОРИТЕТНОГО агрегата + /v1/user/total_balance (total_usd_value уже включает и токены, и DeFi), + а НЕ считается вручную суммой отдельных запросов. tokens_data / protocols_data / nft_data собираются как детализация для - экспорта; result-dict полностью совместим с DeBank-веткой. + экспорта; result-dict совместим со старой DeBank-схемой. """ client = RabbyClient(proxy=proxy) @@ -154,15 +151,22 @@ def _fetch_snapshot_rabby(address: str, proxy: str) -> dict[str, Any]: if c.get("id") and (c.get("usd_value") or 0) > 0 ] - # 2) Токены по каждой непустой сети (is_all=false → только проверенные/core). + # 2) Токены: все сети одним запросом (cache_token_list); при сбое — + # фолбэк на по-сетевой token_list (is_all=false → только core). + # Пустой chain_list (как у расширения) → запросы токенов не нужны. tokens: list[dict] = [] - for chain in nonzero_chains: - chain_tokens = client.get_token_list(address, chain_id=chain, is_all=not RABBY_IS_CORE) - for t in chain_tokens: - if RABBY_IS_CORE and not t.get("is_core", True): - continue - if t.get("is_verified", True) and not t.get("is_scam", False): - tokens.append(t) + 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). @@ -237,7 +241,7 @@ def _fetch_snapshot_rabby(address: str, proxy: str) -> dict[str, Any]: pass nft_data.sort(key=lambda x: (x["chain"], x["name"])) - # Лог при аномалии (для сравнения с DeBank-веткой). + # Лог сырых данных при аномальном балансе. _write_log(address, total_usd, tokens_usd, protocols_usd, tokens, portfolio) token_chains = {t.get("chain", "") for t in tokens if t.get("chain")} @@ -266,141 +270,6 @@ def _fetch_snapshot_rabby(address: str, proxy: str) -> dict[str, Any]: } -def _fetch_snapshot(address: str, proxy: str) -> dict[str, Any]: - """Диспетчер источника балансов (BALANCE_SOURCE): rabby | debank.""" - if BALANCE_SOURCE == "debank": - return _fetch_snapshot_debank(address, proxy) - return _fetch_snapshot_rabby(address, proxy) - - -def _fetch_snapshot_debank(address: str, proxy: str) -> dict[str, Any]: - """Одна независимая выборка баланса кошелька через DeBank API (tokens + portfolio + NFT). - - Бросает исключение при сетевой ошибке. Возвращает result-dict со status=OK. - - ВНИМАНИЕ: под высокой конкуренцией DeBank иногда отдаёт ответ от ДРУГОГО - адреса (response contamination) — такая выборка внутренне консистентна и - в одиночку неотличима от настоящей. Отсев делает корроборация в - check_wallet(): фантом случаен и не повторяется, истинный баланс стабилен. - """ - client = DeBankClient(proxy=proxy) - - # Токены в кошельке — фильтруем скам и неверифицированные - tokens = client.get_total_balance_cache(address) - tokens = [ - t for t in tokens - if t.get("is_verified", True) and not t.get("is_scam", False) - ] - - portfolio = client.get_portfolio(address) - - tokens_usd = sum(t.get("price", 0) * t.get("amount", 0) for t in tokens) - - protocols_data = [] - protocols_usd = 0.0 - for proto in portfolio: - proto_value = sum( - _safe_position_value(item) - for item in proto.get("portfolio_item_list", []) - ) - if round(proto_value, 2) < MIN_VALUE_DISPLAY: - continue - protocols_usd += proto_value - - positions = [] - for item in proto.get("portfolio_item_list", []): - net = _safe_position_value(item) - if round(net, 2) < MIN_VALUE_DISPLAY: - continue - detail = item.get("detail", {}) - 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", []) - ] - positions.append({ - "type": item.get("name", ""), - "value": round(net, 2), - "supply": ", ".join(supply), - "rewards": ", ".join(reward), - }) - - protocols_data.append({ - "logo": proto.get("logo_url") or "", - "name": proto.get("name", "?"), - "chain": proto.get("chain", "?"), - "value": round(proto_value, 2), - "positions": positions, - }) - - protocols_data.sort(key=lambda p: p["value"], reverse=True) - - nft_usd = 0.0 - nft_data: list[dict] = [] - nft_chains: set[str] = set() - try: - nft_chains_list = client.get_nft_used_chains(address) - for chain in (nft_chains_list or [])[:10]: - collections = client.get_nft_collection_list(address, chain) - for col in collections: - amount = col.get("amount") or col.get("nft_count", 0) - if not amount: - continue - nft_chains.add(col.get("chain_id") or chain) - nft_data.append({ - "name": col.get("name", "?"), - "chain": col.get("chain_id") or chain, - "amount": amount, - }) - except Exception: - pass - nft_data.sort(key=lambda x: (x["chain"], x["name"])) - - total_usd = tokens_usd + protocols_usd - - # Лог сырых данных при аномалии (помогает увидеть, что именно отсеяла корроборация) - _write_log(address, total_usd, tokens_usd, protocols_usd, tokens, portfolio) - - chains = sorted({t.get("chain", "") for t in tokens if t.get("chain")} | nft_chains) - - tokens_data = [ - { - "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 - ] - - 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 { - "address": address, - "total_usd": total_usd, - "tokens_usd": tokens_usd, - "protocols_usd": protocols_usd, - "nft_usd": nft_usd, - "tokens": len(tokens_data), - "chains": ", ".join(chains), - "top_tokens": top_str, - "tokens_data": tokens_data, - "protocols_data": protocols_data, - "nft_data": nft_data, - "proxy": proxy or "—", - "status": "OK", - "error": "", - "corroborated": True, - } - - def check_wallet(address: str, proxy_manager: ProxyManager, wallet_idx: int = -1) -> dict[str, Any]: """Проверяет баланс одного кошелька с защитой от «фантомных» балансов. diff --git a/debank_checker/config.py b/debank_checker/config.py index 63a459f..4aa78d8 100644 --- a/debank_checker/config.py +++ b/debank_checker/config.py @@ -9,19 +9,19 @@ # API REQUEST_TIMEOUT = 3 # секунд (быстрый failover при мёртвых прокси, см. docs/DEBUG_REPORT.md) -API_KEY_INIT = "3b92c003-ddc1-4c2d-b36e-781838f362c5" -# --- Источник балансов --------------------------------------------------- -# "rabby" — получать балансы через Rabby API (по умолчанию; см. api/rabby_client.py). -# total берётся из /v1/user/total_balance напрямую → нет фантомов. -# "debank" — старое поведение через DeBank API (оставлено для отката). -BALANCE_SOURCE = "rabby" - -# Rabby API (начальный ключ ротируется сервером через x-set-api-key). -# x-version = версия веб-клиента Rabby, под которую записан HAR; при поломке -# подписи — обнови из свежего HAR. +# --- Rabby API ----------------------------------------------------------- +# Init-ключ ротируется сервером через x-set-api-key; ротированный ключ хранится +# в общем магазине api/rabby_client.py — новый клиент на попытку продолжает +# с последнего выданного, а не стартует заново с init-ключа. RABBY_API_KEY_INIT = "7cee6f31-6611-4821-beb8-6ca9e29ed965" -RABBY_CLIENT_VERSION = "0.94.1" +# Время выдачи init-ключа (из HAR веб-клиента). Отправляется как x-api-time: +# сервер ожидает время ВЫДАЧИ ключа, а не время запроса — вместе с кейсингом +# заголовков и отсутствием x-api-ver это сверено с HAR (иначе фейковый 429). +RABBY_API_KEY_INIT_TIME = 1762656362 +# x-version = версия клиента Rabby (из HAR расширения), под которую записана +# схема; при поломке подписи — обнови из свежего HAR. +RABBY_CLIENT_VERSION = "0.94.2" # is_core=true → в total и в токенах только проверенные (core) токены, скам отсекается. RABBY_IS_CORE = True @@ -37,19 +37,11 @@ MIN_VALUE_DISPLAY = 0.01 # --- Защита от «фантомных» балансов ------------------------------------- -# Под высокой конкуренцией DeBank-API/edge иногда возвращает ОТВЕТ ОТ ДРУГОГО -# адреса (response contamination: ~1 на 30 запросов при 40+ воркерах; при -# последовательных запросах НЕ воспроизводится). Из-за этого на кошельке с $15 -# вдруг «появляются» сотни тысяч/миллионы. Данные при этом внутренне -# консистентны (верифицированные токены, реальные пулы) — пересчётом одной -# выборки фантом не отличить, нужна КОРРОБОРАЦИЯ независимыми запросами. -# -# Баланс принимается, только если подтверждён двумя независимыми выборками, -# сошедшимися в пределах допуска. Фантом случаен и не повторяется → отбрасываем; -# истинное значение стабильно → подтверждается. -# Для Rabby (BALANCE_SOURCE="rabby") total берётся из авторитетного агрегата -# total_balance, поэтому по умолчанию MIN_AGREE=1 — корроборация не тратит -# лишние запросы, но механика остаётся как страховка. Для DeBank подними до 2. +# Баланс принимается, только если подтверждён CORROBORATION_MIN_AGREE +# независимыми выборками, сошедшимися в пределах допуска. Для Rabby итог +# авторитетен (total_usd_value из /v1/user/total_balance), поэтому по +# умолчанию MIN_AGREE=1 — корроборация не тратит лишние запросы, но механика +# остаётся как страховка. CORROBORATION_ENABLED = True # False — принять первую же выборку CORROBORATION_MIN_AGREE = 1 # сошедшихся выборок нужно для приёма CORROBORATION_MAX_FETCHES = 8 # бюджет успешных выборок на кошелёк diff --git a/main.py b/main.py index f8f6843..f077d2e 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ """ -DeBank Balance Checker — точка входа +DeBank Checker — точка входа (балансы через Rabby API) """ import sys diff --git a/tests/test_rabby_snapshot.py b/tests/test_rabby_snapshot.py index 1e0214f..a7019c2 100644 --- a/tests/test_rabby_snapshot.py +++ b/tests/test_rabby_snapshot.py @@ -1,4 +1,4 @@ -"""Тесты сборки снапшота баланса через Rabby (_fetch_snapshot_rabby). +"""Тесты сборки снапшота баланса через Rabby (_fetch_snapshot). Мокаем ТОЛЬКО внешний HTTP-клиент (RabbyClient) — граница системы. Логика маппинга ответов в result-dict тестируется как есть. @@ -23,18 +23,29 @@ def get_total_balance(self, address: str, is_core: bool = True) -> dict: ], } + def get_cache_token_list(self, address: str) -> list: + """Кэш всех сетей одним запросом (как реальный endpoint).""" + return [ + {"symbol": "USDC", "chain": "eth", "amount": 371.32, "price": 1.0, + "is_verified": True, "is_core": True, "is_scam": False, + "logo_url": "u"}, + # скам-токен — должен быть отфильтрован + {"symbol": "SCAM", "chain": "eth", "amount": 1e9, "price": 1.0, + "is_verified": True, "is_core": True, "is_scam": True}, + # не-core токен — должен быть отфильтрован при is_core + {"symbol": "RANDOM", "chain": "eth", "amount": 100, "price": 5.0, + "is_verified": True, "is_core": False, "is_scam": False}, + {"symbol": "ETH", "chain": "arb", "amount": 0.004, "price": 2170.0, + "is_verified": True, "is_core": True, "is_scam": False}, + ] + 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, "is_verified": True, "is_core": True, "is_scam": False, "logo_url": "u"}, - # скам-токен — должен быть отфильтрован - {"symbol": "SCAM", "chain": "eth", "amount": 1e9, "price": 1.0, - "is_verified": True, "is_core": True, "is_scam": True}, - # не-core токен — должен быть отфильтрован при is_core - {"symbol": "RANDOM", "chain": "eth", "amount": 100, "price": 5.0, - "is_verified": True, "is_core": False, "is_scam": False}, ], "arb": [ {"symbol": "ETH", "chain": "arb", "amount": 0.004, "price": 2170.0, @@ -80,14 +91,14 @@ def _patch(monkeypatch): def test_total_usd_comes_from_authoritative_total_balance(monkeypatch): """total_usd берётся из total_usd_value напрямую, НЕ суммируется вручную.""" _patch(monkeypatch) - snap = checker._fetch_snapshot_rabby("0xabc", "http://proxy") + snap = checker._fetch_snapshot("0xabc", "http://proxy") assert snap["total_usd"] == 380.0 def test_scam_and_non_core_tokens_filtered(monkeypatch): """SCAM (is_scam) и RANDOM (не core) не попадают в tokens_data.""" _patch(monkeypatch) - snap = checker._fetch_snapshot_rabby("0xabc", "http://proxy") + snap = checker._fetch_snapshot("0xabc", "http://proxy") symbols = {t["symbol"] for t in snap["tokens_data"]} assert "SCAM" not in symbols assert "RANDOM" not in symbols @@ -97,14 +108,14 @@ def test_scam_and_non_core_tokens_filtered(monkeypatch): def test_zero_value_chains_skipped(monkeypatch): """Сеть bsc с usd_value=0 не запрашивается → её токенов нет.""" _patch(monkeypatch) - snap = checker._fetch_snapshot_rabby("0xabc", "http://proxy") + snap = checker._fetch_snapshot("0xabc", "http://proxy") assert "bsc" not in snap["chains"] def test_protocols_mapped_with_chain_from_token(monkeypatch): """DeFi-протокол попадает в protocols_data, chain берётся из токена позиции.""" _patch(monkeypatch) - snap = checker._fetch_snapshot_rabby("0xabc", "http://proxy") + snap = checker._fetch_snapshot("0xabc", "http://proxy") assert len(snap["protocols_data"]) == 1 proto = snap["protocols_data"][0] assert proto["name"] == "Hyperliquid" @@ -115,7 +126,7 @@ def test_protocols_mapped_with_chain_from_token(monkeypatch): def test_scam_nft_collection_filtered(monkeypatch): """Скам-коллекция отфильтрована, валидная — с количеством из nft_list.""" _patch(monkeypatch) - snap = checker._fetch_snapshot_rabby("0xabc", "http://proxy") + snap = checker._fetch_snapshot("0xabc", "http://proxy") names = {n["name"] for n in snap["nft_data"]} assert names == {"GMCards"} assert snap["nft_data"][0]["amount"] == 2 @@ -124,7 +135,7 @@ def test_scam_nft_collection_filtered(monkeypatch): def test_result_dict_schema_matches_debank(monkeypatch): """result-dict содержит все ключи, которые ждут экспортёры/меню.""" _patch(monkeypatch) - snap = checker._fetch_snapshot_rabby("0xabc", "http://proxy") + snap = checker._fetch_snapshot("0xabc", "http://proxy") required = { "address", "total_usd", "tokens_usd", "protocols_usd", "nft_usd", "tokens", "chains", "top_tokens", "tokens_data", "protocols_data", @@ -140,6 +151,9 @@ class EmptyClient(FakeRabbyClient): def get_total_balance(self, address, is_core=True): return {"total_usd_value": 0.0, "chain_list": []} + def get_cache_token_list(self, address): + return [] + def get_complex_app_list(self, address): return [] @@ -147,7 +161,38 @@ def get_collection_list(self, address, is_all=True): return [] monkeypatch.setattr(checker, "RabbyClient", EmptyClient) - snap = checker._fetch_snapshot_rabby("0xabc", "http://proxy") + snap = checker._fetch_snapshot("0xabc", "http://proxy") assert snap["total_usd"] == 0.0 assert snap["tokens_data"] == [] assert snap["status"] == "OK" + + +def test_token_fallback_on_cache_failure(monkeypatch): + """Сбой cache_token_list → токены собираются по-сетевым token_list.""" + class CacheBrokenClient(FakeRabbyClient): + def get_cache_token_list(self, address): + raise RuntimeError("HTTP Error 429: ") + + monkeypatch.setattr(checker, "RabbyClient", 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.""" + calls = {"cache": 0, "per_chain": 0} + + class CountingClient(FakeRabbyClient): + def get_cache_token_list(self, address): + calls["cache"] += 1 + return super().get_cache_token_list(address) + + 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) + snap = checker._fetch_snapshot("0xabc", "http://proxy") + assert calls == {"cache": 1, "per_chain": 0} + assert len(snap["tokens_data"]) == 2