diff --git a/.env.example b/.env.example index 791a952..e3e8a46 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,8 @@ ALPHAX_NODEREAL_MAX_LOGS_PER_TOKEN=25 ALPHAX_NODEREAL_RAW_TRANSFER_ENABLED=1 ALPHAX_NODEREAL_RAW_BLOCK_LOOKBACK=1 ALPHAX_NODEREAL_RAW_MAX_LOGS_PER_CHAIN=30 +ALPHAX_NODEREAL_AUTO_MAPPING_ENABLED=1 +ALPHAX_NODEREAL_AUTO_MAPPING_CONFIDENCE=82 ALPHAX_ONCHAIN_CANDIDATE_ENABLED=1 ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE=70 ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE=70 diff --git a/app/config/system_config.py b/app/config/system_config.py index 378b3d9..8d05d29 100644 --- a/app/config/system_config.py +++ b/app/config/system_config.py @@ -84,6 +84,8 @@ def default_onchain_config(default_chains=("ethereum", "bsc")): "nodereal_raw_transfer_enabled": _env_bool("ALPHAX_NODEREAL_RAW_TRANSFER_ENABLED", True), "nodereal_raw_block_lookback": _env_int("ALPHAX_NODEREAL_RAW_BLOCK_LOOKBACK", 1), "nodereal_raw_max_logs_per_chain": _env_int("ALPHAX_NODEREAL_RAW_MAX_LOGS_PER_CHAIN", 30), + "nodereal_auto_mapping_enabled": _env_bool("ALPHAX_NODEREAL_AUTO_MAPPING_ENABLED", True), + "nodereal_auto_mapping_confidence": _env_int("ALPHAX_NODEREAL_AUTO_MAPPING_CONFIDENCE", 82), "candidate_enabled": _env_bool("ALPHAX_ONCHAIN_CANDIDATE_ENABLED", True), "candidate_min_score": _env_float("ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE", 70), "candidate_min_confidence": _env_int("ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE", 70), diff --git a/app/services/nodereal_client.py b/app/services/nodereal_client.py index 5bb8ae4..960de9c 100644 --- a/app/services/nodereal_client.py +++ b/app/services/nodereal_client.py @@ -64,6 +64,10 @@ class NodeRealClient: result = self.call(chain, "eth_getLogs", [log_filter]) return result if isinstance(result, list) else [] + def eth_call(self, chain: str, to_address: str, data: str, block: str = "latest") -> str: + result = self.call(chain, "eth_call", [{"to": to_address, "data": data}, block]) + return str(result or "") + def token_holder_count(self, chain: str, contract_address: str) -> int: return _hex_to_int(self.call(chain, "nr_getTokenHolderCount", [contract_address])) @@ -87,4 +91,3 @@ def _hex_to_int(value: Any) -> int: return int(text, 16) if text.startswith("0x") else int(text) except Exception: return 0 - diff --git a/app/services/onchain_monitor.py b/app/services/onchain_monitor.py index 27b8085..6beadb2 100644 --- a/app/services/onchain_monitor.py +++ b/app/services/onchain_monitor.py @@ -73,6 +73,9 @@ DEXSCREENER_RAW_ENDPOINTS = ( ("token_boost_top", "https://api.dexscreener.com/token-boosts/top/v1"), ) TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" +ERC20_SYMBOL_SELECTOR = "0x95d89b41" +ERC20_NAME_SELECTOR = "0x06fdde03" +ERC20_DECIMALS_SELECTOR = "0x313ce567" def _env_bool(name, default=False): @@ -129,6 +132,8 @@ def get_onchain_params(): "nodereal_raw_transfer_enabled": bool(cfg.get("nodereal_raw_transfer_enabled", True)), "nodereal_raw_block_lookback": int(cfg.get("nodereal_raw_block_lookback") or 1), "nodereal_raw_max_logs_per_chain": int(cfg.get("nodereal_raw_max_logs_per_chain") or 30), + "nodereal_auto_mapping_enabled": bool(cfg.get("nodereal_auto_mapping_enabled", True)), + "nodereal_auto_mapping_confidence": int(cfg.get("nodereal_auto_mapping_confidence") or 82), "candidate_enabled": bool(cfg.get("candidate_enabled", True)), "candidate_min_score": float(cfg.get("candidate_min_score") or 70), "candidate_min_confidence": int(cfg.get("candidate_min_confidence") or 70), @@ -795,6 +800,107 @@ def _topic_to_address(topic): return "" +def _decode_abi_string(value): + text = str(value or "").strip() + if not text or text == "0x": + return "" + payload = text[2:] if text.startswith("0x") else text + try: + raw = bytes.fromhex(payload) + except Exception: + return "" + if not raw: + return "" + try: + if len(raw) >= 96: + offset = int.from_bytes(raw[:32], "big") + if 0 <= offset + 32 <= len(raw): + length = int.from_bytes(raw[offset:offset + 32], "big") + body = raw[offset + 32:offset + 32 + length] + return body.decode("utf-8", errors="ignore").strip("\x00 ").strip() + return raw.rstrip(b"\x00").decode("utf-8", errors="ignore").strip() + except Exception: + return "" + + +def _clean_erc20_symbol(value): + symbol = str(value or "").upper().strip().replace("$", "") + symbol = "".join(ch for ch in symbol if ch.isalnum()) + if symbol.endswith("USDT") and len(symbol) > 4: + symbol = symbol[:-4] + return symbol[:20] + + +def _is_auto_mapping_symbol_allowed(base, token_name=""): + base = _clean_erc20_symbol(base) + if not base: + return False + symbol = f"{base}/USDT" + if not _tradable_symbol(symbol): + return False + if base in NON_TARGET_NATIVE_BASES: + return False + text = f"{base} {token_name or ''}".lower() + if any(marker in text for marker in BRIDGED_TOKEN_MARKERS): + return False + return True + + +def _read_erc20_metadata(client, chain, contract): + metadata = {"symbol": "", "name": "", "decimals": 18} + try: + metadata["symbol"] = _decode_abi_string(client.eth_call(chain, contract, ERC20_SYMBOL_SELECTOR)) + except Exception: + metadata["symbol"] = "" + try: + metadata["name"] = _decode_abi_string(client.eth_call(chain, contract, ERC20_NAME_SELECTOR)) + except Exception: + metadata["name"] = "" + try: + decimals = _hex_to_int(client.eth_call(chain, contract, ERC20_DECIMALS_SELECTOR)) + if 0 <= decimals <= 36: + metadata["decimals"] = decimals + except Exception: + pass + metadata["symbol"] = _clean_erc20_symbol(metadata.get("symbol")) + return metadata + + +def _auto_map_nodereal_contract(client, chain, contract, cfg=None): + cfg = cfg or get_onchain_params() + if not cfg.get("nodereal_auto_mapping_enabled", True): + return None + existing = find_mapping_by_contract(chain, contract) + if existing: + return existing + metadata = _read_erc20_metadata(client, chain, contract) + base = metadata.get("symbol") or "" + if not _is_auto_mapping_symbol_allowed(base, metadata.get("name")): + return None + symbol = normalize_symbol(base) + confidence = max(1, min(95, int(cfg.get("nodereal_auto_mapping_confidence") or 82))) + mapping_id = onchain_db.upsert_token_mapping( + symbol=symbol, + chain=chain, + contract_address=contract, + source="nodereal_erc20_metadata", + confidence=confidence, + raw=metadata, + is_active=True, + ) + if not mapping_id: + return None + return { + "id": mapping_id, + "symbol": symbol, + "chain": str(chain or "").lower(), + "contract_address": contract, + "source": "nodereal_erc20_metadata", + "confidence": confidence, + "raw_json": json.dumps(metadata, ensure_ascii=False), + } + + def _nodereal_client(cfg=None): cfg = cfg or get_onchain_params() return NodeRealClient( @@ -880,6 +986,30 @@ def _raw_event_from_nodereal_transfer(log, chain): } +def _apply_raw_event_mapping(raw_event, client=None, cfg=None): + item = dict(raw_event or {}) + chain = str(item.get("chain") or "").lower() + contract = str(item.get("token_address") or "").strip() + if not chain or not contract: + return item + mapping = find_mapping_by_contract(chain, contract) + if not mapping and client: + mapping = _auto_map_nodereal_contract(client, chain, contract, cfg=cfg) + if mapping: + item["mapped_symbol"] = normalize_symbol(mapping.get("symbol")) + item["mapping_status"] = "mapped" + item["symbol_guess"] = item.get("symbol_guess") or item["mapped_symbol"].split("/")[0] + item["raw"] = { + **(item.get("raw") or {}), + "mapping": { + "symbol": item["mapped_symbol"], + "source": mapping.get("source") or "", + "confidence": mapping.get("confidence") or 0, + }, + } + return item + + def _metric_from_nodereal_holder_count(holder_count, mapping): symbol = normalize_symbol(mapping.get("symbol")) chain = str(mapping.get("chain") or "").lower() @@ -1039,7 +1169,7 @@ def fetch_nodereal_raw_events(client=None, cfg=None, limit=60): continue item = _raw_event_from_nodereal_transfer(log, chain) if item: - raw_items.append(item) + raw_items.append(_apply_raw_event_mapping(item, client=client, cfg=cfg)) raw_items.sort(key=lambda item: item.get("amount") or 0, reverse=True) for item in raw_items[:per_chain]: if insert_onchain_raw_event(item): diff --git a/tests/test_onchain_tracking.py b/tests/test_onchain_tracking.py index ba5b832..656430d 100644 --- a/tests/test_onchain_tracking.py +++ b/tests/test_onchain_tracking.py @@ -388,8 +388,12 @@ def test_nodereal_records_raw_events_without_strategy_mappings(monkeypatch, tmp_ def block_number(self, chain): return 1000 + def token_holder_count(self, chain, contract): + return 0 + def get_logs(self, chain, log_filter): - assert "address" not in log_filter + if "address" in log_filter: + return [] return [ { "address": "0xabc", @@ -418,6 +422,65 @@ def test_nodereal_records_raw_events_without_strategy_mappings(monkeypatch, tmp_ assert raw["items"][0]["event_type"] == "evm_transfer" +def test_nodereal_auto_maps_raw_event_from_erc20_metadata(monkeypatch, tmp_path): + _temp_db(monkeypatch, tmp_path) + monkeypatch.setenv("ALPHAX_NODEREAL_API_KEY", "test-key") + monkeypatch.setenv("ALPHAX_NODEREAL_CHAINS", "ethereum") + + def abi_string(value): + body = value.encode() + padded = body + (b"\x00" * ((32 - len(body) % 32) % 32)) + return "0x" + (32).to_bytes(32, "big").hex() + len(body).to_bytes(32, "big").hex() + padded.hex() + + class MetadataNodeRealClient: + def supports_chain(self, chain): + return chain == "ethereum" + + def block_number(self, chain): + return 1000 + + def token_holder_count(self, chain, contract): + return 0 + + def get_logs(self, chain, log_filter): + if "address" in log_filter: + return [] + return [ + { + "address": "0xstorj", + "transactionHash": "0xrawtx", + "data": hex(1000 * 10**8), + "topics": [ + onchain_monitor.TRANSFER_TOPIC, + "0x0000000000000000000000001111111111111111111111111111111111111111", + "0x0000000000000000000000002222222222222222222222222222222222222222", + ], + } + ] + + def eth_call(self, chain, to_address, data, block="latest"): + if data == onchain_monitor.ERC20_SYMBOL_SELECTOR: + return abi_string("STORJ") + if data == onchain_monitor.ERC20_NAME_SELECTOR: + return abi_string("Storj") + if data == onchain_monitor.ERC20_DECIMALS_SELECTOR: + return hex(8) + return "0x" + + monkeypatch.setattr(onchain_monitor, "_nodereal_client", lambda cfg=None: MetadataNodeRealClient()) + + result = onchain_monitor.fetch_nodereal_events(limit=10) + + assert result["errors"] == [] + assert len(result["raw_events"]) == 1 + raw = onchain_db.list_onchain_raw_events(hours=50000) + assert raw["items"][0]["mapping_status"] == "mapped" + assert raw["items"][0]["mapped_symbol"] == "STORJ/USDT" + mappings = onchain_db.get_token_mappings("STORJ/USDT") + assert len(mappings) == 1 + assert mappings[0]["source"] == "nodereal_erc20_metadata" + + def test_nodereal_seeds_configured_token_mappings_from_env(monkeypatch, tmp_path): _temp_db(monkeypatch, tmp_path) monkeypatch.setenv("ALPHAX_NODEREAL_API_KEY", "test-key")