rmi-backend/app/unified_scanner.py

1522 lines
61 KiB
Python

"""
RugMunch Unified Wallet Scanner + Trending Engine
==================================================
Powered by SENTINEL + Wallet Memory Bank + Helius + Token Scanner integration.
FREE: Helius on-chain data (age, tx, volume, balances) + SENTINEL labels
PRO: Wallet Memory Bank identity + cluster intel + GMGN scoring
ELITE: Token portfolio risk scan + full behavioral analysis
Chains: solana, ethereum, base, bsc, arbitrum, polygon, avalanche,
optimism, fantom, linea, zksync, scroll, mantle
"""
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
import httpx
logger = logging.getLogger(__name__)
# ─── CHAIN CONFIG ─────────────────────────────────────────────────
CHAINS = {
"solana": {"id": "solana", "explorer": "https://solscan.io"},
"ethereum": {"id": "1", "explorer": "https://etherscan.io"},
"base": {"id": "8453", "explorer": "https://basescan.org"},
"bsc": {"id": "56", "explorer": "https://bscscan.com"},
"arbitrum": {"id": "42161", "explorer": "https://arbiscan.io"},
"polygon": {"id": "137", "explorer": "https://polygonscan.com"},
"avalanche": {"id": "43114", "explorer": "https://snowtrace.io"},
"optimism": {"id": "10", "explorer": "https://optimistic.etherscan.io"},
"fantom": {"id": "250", "explorer": "https://ftmscan.com"},
"linea": {"id": "59144", "explorer": "https://lineascan.build"},
"zksync": {"id": "324", "explorer": "https://explorer.zksync.io"},
"scroll": {"id": "534352", "explorer": "https://scrollscan.com"},
"mantle": {"id": "5000", "explorer": "https://mantlescan.xyz"},
}
# ═══════════════════════════════════════════════════════════════
# WALLET SCANNER - Powered by SENTINEL + Helius + Wallet Memory
# ═══════════════════════════════════════════════════════════════
@dataclass
class WalletRiskFactors:
"""100+ risk factors scored per wallet - populated from real on-chain data."""
wallet_age_days: int = 0
first_tx_date: str | None = None
total_tx_count: int = 0
tx_frequency_per_day: float = 0
days_since_last_tx: int = 0
active_chains: int = 1
total_volume_usd: float = 0
avg_tx_value_usd: float = 0
max_single_tx_usd: float = 0
funding_source: str | None = None
funding_source_label: str | None = None
funding_source_is_cex: bool = False
funding_source_is_mixer: bool = False
funding_from_sanctioned: bool = False
funding_from_scammer: bool = False
funding_hop_count: int = 0
cross_chain_funding: bool = False
tornado_cash_exposure: bool = False
mixer_interactions: int = 0
unique_tokens_held: int = 0
token_launches_participated: int = 0
sniper_activity_score: float = 0
sandwich_attacks_executed: int = 0
flash_loan_usage: int = 0
rug_pull_launcher_count: int = 0
honeypot_tokens_owned: int = 0
tokens_where_top_holder: int = 0
cex_deposits: int = 0
cex_withdrawals: int = 0
dex_swaps: int = 0
dex_volume_usd: float = 0
cluster_id: str | None = None
cluster_size: int = 0
cluster_shared_funding: bool = False
known_cluster_type: str = ""
entity_label: str | None = None
entity_confidence: float = 0
is_labeled_scammer: bool = False
is_labeled_sanctioned: bool = False
is_labeled_exchange: bool = False
is_labeled_bot: bool = False
is_labeled_whale: bool = False
is_labeled_insider: bool = False
linked_wallets_count: int = 0
insider_trading_confidence: float = 0
buys_before_pumps: int = 0
sells_before_dumps: int = 0
wash_trading_score: float = 0
pump_and_dump_participant: bool = False
new_token_flipper: bool = False
round_trip_patterns: int = 0
scam_token_exposure: int = 0
phishing_victim: bool = False
sybil_score: float = 0
exploit_exposure: int = 0
protocol_hack_interactions: int = 0
approval_risk_score: float = 0
current_balance_usd: float = 0
portfolio_diversity_score: float = 0
stablecoin_ratio: float = 0
bluechip_ratio: float = 0
memecoin_ratio: float = 0
realized_pnl_usd: float = 0
unrealized_pnl_usd: float = 0
win_rate: float = 0
# Token holdings risk (from integrated token scanner)
held_token_risks: list[dict[str, Any]] = field(default_factory=list)
highest_risk_token: dict[str, Any] | None = None
# Overall
total_risk_score: int = 0
risk_category: str = "unknown"
confidence: float = 0
data_sources: list[str] = field(default_factory=list)
# ═══════════════════════════════════════════
# RESULT CACHE - Redis-backed, 1hr TTL
# ═══════════════════════════════════════════
import hashlib # noqa: E402
async def _cache_get(key: str) -> dict | None:
"""Get cached wallet scan result."""
try:
import json
import os
import redis.asyncio as aioredis
r = await aioredis.from_url(
f"redis://{os.getenv('REDIS_HOST', 'rmi-redis')}:{os.getenv('REDIS_PORT', '6379')}",
password=os.getenv("REDIS_PASSWORD", "") or None,
)
cached = await r.get(f"rmi:wallet_cache:{key}")
await r.close()
if cached:
return json.loads(cached)
except Exception:
logger.debug("wallet cache get failed", exc_info=True)
return None
async def _cache_set(key: str, data: dict, ttl: int = 3600):
"""Cache wallet scan result for 1 hour."""
try:
import json
import os
import redis.asyncio as aioredis
r = await aioredis.from_url(
f"redis://{os.getenv('REDIS_HOST', 'rmi-redis')}:{os.getenv('REDIS_PORT', '6379')}",
password=os.getenv("REDIS_PASSWORD", "") or None,
)
await r.setex(f"rmi:wallet_cache:{key}", ttl, json.dumps(data))
await r.close()
except Exception:
logger.debug("wallet cache set failed", exc_info=True)
# ═══════════════════════════════════════════
# HELIUS CREDENTIAL POOL - Primary + Fallback
# ═══════════════════════════════════════════
_HELIUS_KEYS = None
_HELIUS_INDEX = 0
def _get_helius_key() -> str | None:
"""Round-robin through Helius API keys. Returns None if none configured."""
global _HELIUS_KEYS, _HELIUS_INDEX
if _HELIUS_KEYS is None:
import os
keys = []
# Primary key
k1 = os.getenv("HELIUS_API_KEY", "").strip()
if k1:
keys.append(k1)
# Fallback keys (comma-separated or separate env vars)
k2 = os.getenv("HELIUS_API_KEY_2", "").strip()
if k2:
keys.append(k2)
k3 = os.getenv("HELIUS_API_KEY_3", "").strip()
if k3:
keys.append(k3)
_HELIUS_KEYS = keys
if not _HELIUS_KEYS:
return None
key = _HELIUS_KEYS[_HELIUS_INDEX % len(_HELIUS_KEYS)]
_HELIUS_INDEX += 1
return key
async def _helius_call(method: str, params: list) -> dict | None:
"""Call Helius RPC with credential pool fallback."""
for _attempt in range(3): # try up to 3 keys
key = _get_helius_key()
if not key:
return None
try:
async with httpx.AsyncClient(timeout=12.0) as client:
resp = await client.post(
f"https://mainnet.helius-rpc.com/?api-key={key}",
json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
)
if resp.status_code == 200:
data = resp.json()
if "error" not in data:
return data
# 401/429 = try next key
if resp.status_code in (401, 429):
continue
except Exception:
logger.debug("helius call failed, trying next key", exc_info=True)
continue
return None
async def _fetch_helius_wallet(address: str) -> dict[str, Any]:
"""Fetch wallet age, tx count, balances, and token holdings from Helius (credential pool)."""
result = {}
# Token balances
bal_data = await _helius_call(
"getTokenAccountsByOwner",
[
address,
{"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},
{"encoding": "jsonParsed"},
],
)
if bal_data:
accounts = (bal_data.get("result", {}) or {}).get("value", [])
tokens = []
for acc in accounts:
info = acc.get("account", {}).get("data", {}).get("parsed", {}).get("info", {})
amt = float(info.get("tokenAmount", {}).get("uiAmount", 0) or 0)
mint = info.get("mint", "")
if mint and amt > 0:
tokens.append({"mint": mint, "amount": amt})
result["tokens"] = tokens
result["token_count"] = len(tokens)
result["data_sources"] = ["helius"]
# Transaction signatures for age/activity
sig_data = await _helius_call("getSignaturesForAddress", [address, {"limit": 100}])
if sig_data:
sigs = sig_data.get("result", []) or []
if sigs:
result["tx_count_sample"] = len(sigs)
result["last_tx"] = sigs[0].get("blockTime")
first = sigs[-1].get("blockTime")
if first:
age_seconds = datetime.now(UTC).timestamp() - first
result["wallet_age_days"] = int(age_seconds / 86400)
result["tx_count"] = len(sigs)
result["data_sources"] = [*result.get("data_sources", []), "helius_tx"]
# SOL balance
sol_data = await _helius_call("getBalance", [address])
if sol_data:
bal = sol_data.get("result", {}).get("value", 0)
result["sol_balance"] = bal / 1e9 if bal else 0
return result
# ═══════════════════════════════════════════
# MORALIS - EVM wallet data (multi-chain)
# ═══════════════════════════════════════════
async def _fetch_moralis_wallet(address: str, chain: str) -> dict[str, Any]:
"""Fetch EVM wallet data from Moralis (balance, tx history, token holdings)."""
import os
key = os.getenv("MORALIS_API_KEY", "").strip()
if not key:
return {}
chain_map = {
"ethereum": "eth",
"bsc": "bsc",
"polygon": "polygon",
"arbitrum": "arbitrum",
"base": "base",
"optimism": "optimism",
"avalanche": "avalanche",
"fantom": "fantom",
}
moralis_chain = chain_map.get(chain, "eth")
result = {}
try:
async with httpx.AsyncClient(timeout=15.0) as client:
headers = {"X-API-Key": key}
# Token balances
resp = await client.get(
f"https://deep-index.moralis.io/api/v2.2/{address}/erc20",
params={"chain": moralis_chain},
headers=headers,
)
if resp.status_code == 200:
tokens_data = resp.json()
tokens = []
for t in tokens_data[:50]:
amt = float(t.get("balance_formatted", 0) or 0)
if amt > 0:
tokens.append(
{
"mint": t.get("token_address", ""),
"amount": amt,
"symbol": t.get("symbol", ""),
}
)
result["tokens"] = tokens
result["token_count"] = len(tokens)
result["data_sources"] = [*result.get("data_sources", []), "moralis"]
# Native balance
resp2 = await client.get(
f"https://deep-index.moralis.io/api/v2.2/{address}/balance",
params={"chain": moralis_chain},
headers=headers,
)
if resp2.status_code == 200:
bal = resp2.json().get("balance", 0)
if bal:
result["native_balance"] = float(bal) / 1e18
# Transaction count for activity
resp3 = await client.get(
f"https://deep-index.moralis.io/api/v2.2/{address}",
params={"chain": moralis_chain},
headers=headers,
)
if resp3.status_code == 200:
wallet_data = resp3.json()
if wallet_data.get("transactions"):
txs = wallet_data["transactions"]
result["tx_count"] = len(txs)
result["data_sources"] = [*result.get("data_sources", []), "moralis_tx"]
except Exception:
logger.debug("moralis wallet fetch failed", exc_info=True)
return result
# ═══════════════════════════════════════════
# ETHERSCAN - EVM transaction data (free tier)
# ═══════════════════════════════════════════
ETHERSCAN_APIS = {
"ethereum": "https://api.etherscan.io/api",
"bsc": "https://api.bscscan.com/api",
"polygon": "https://api.polygonscan.com/api",
"arbitrum": "https://api.arbiscan.io/api",
"base": "https://api.basescan.org/api",
"optimism": "https://api-optimistic.etherscan.io/api",
"avalanche": "https://api.snowtrace.io/api",
"fantom": "https://api.ftmscan.com/api",
}
async def _fetch_etherscan_wallet(address: str, chain: str) -> dict[str, Any]:
"""Fetch EVM wallet tx count and age from Etherscan family (free tier, no API key needed for basic calls)."""
if chain not in ETHERSCAN_APIS:
return {}
result = {}
try:
async with httpx.AsyncClient(timeout=12.0) as client:
api_url = ETHERSCAN_APIS[chain]
# Get transaction list (free, no key needed for basic)
resp = await client.get(
api_url,
params={
"module": "account",
"action": "txlist",
"address": address,
"startblock": 0,
"endblock": 99999999,
"page": 1,
"offset": 10,
"sort": "asc",
},
)
if resp.status_code == 200:
data = resp.json()
if data.get("status") == "1":
txs = data.get("result", [])
if txs:
first_ts = int(txs[0].get("timeStamp", 0))
last_ts = int(txs[-1].get("timeStamp", 0))
if first_ts:
result["wallet_age_days"] = int(
(datetime.now(UTC).timestamp() - first_ts) / 86400
)
if last_ts:
result["last_tx"] = last_ts
result["tx_count_sample"] = len(txs)
result["data_sources"] = ["etherscan"]
except Exception:
logger.debug("etherscan wallet fetch failed", exc_info=True)
return result
# ═══════════════════════════════════════════
# SENTINEL INTEGRATION - Labels + Intel
# ═══════════════════════════════════════════
async def _fetch_sentinel_labels(address: str, chain: str) -> dict[str, Any]:
"""Get rich labels from SENTINEL address_labeler (6 sources)."""
try:
from app.domains.scanners.address_labeler import AddressLabeler
labeler = AddressLabeler()
result = await labeler.analyze(address, chain)
if result:
return {
"labels": getattr(result, "labels", []),
"category": getattr(result, "category", ""),
"risk_score": getattr(result, "risk_score", 0),
"sources": getattr(result, "sources", []),
}
except Exception as e:
logger.debug(f"SENTINEL labels failed for {address}: {e}")
return {}
async def _fetch_wallet_memory(address: str, chain: str) -> dict[str, Any]:
"""Cross-chain identity + cluster intel from Wallet Memory Bank."""
try:
from app.wallet_memory.engine import get_wallet_engine
engine = get_wallet_engine()
intel = await engine.get_deployer_intelligence(address, chain)
if intel:
return {
"entity_id": intel.get("entity_id"),
"linked_wallets": intel.get("linked_wallets", []),
"risk_score": intel.get("risk_score", 0),
"scam_associations": intel.get("scam_associations", []),
"deployer_history": intel.get("deployer_history"),
"cross_chain_presence": intel.get("cross_chain_presence"),
}
except Exception as e:
logger.debug(f"Wallet Memory Bank failed for {address}: {e}")
return {}
async def _scan_held_tokens(tokens: list, chain: str) -> dict[str, Any]:
"""Scan held tokens using SENTINEL - parallel with full enrichments."""
if not tokens:
return {"scanned": 0, "risks": [], "deployers_to_flag": []}
# Sort by amount (largest first), cap at 5 for speed
sorted_tokens = sorted(
tokens, key=lambda t: float(t.get("amount", 0) if isinstance(t, dict) else 0), reverse=True
)
mints = []
for token in sorted_tokens[:5]: # Top 5 by balance
mint = token.get("mint", "") if isinstance(token, dict) else token
if mint:
mints.append(mint)
if not mints:
return {"scanned": 0, "risks": [], "deployers_to_flag": []}
# Parallel scan all tokens
async def _scan_one(mint: str) -> dict | None:
try:
from app.token_scanner import scan_token
scan = await scan_token(mint, chain, tier="free")
enrich = {}
if hasattr(scan, "free") and isinstance(scan.free, dict):
# Pull key enrichments for wallet context
e = scan.free
enrich = {
"simulation": e.get("simulation"),
"holders": e.get("holders"),
"deployer": e.get("deployer"),
"deep_deployer": e.get("deep_deployer"),
"birdeye": e.get("birdeye"),
"copycat_check": e.get("copycat_check"),
"volume_anomaly": e.get("volume_anomaly"),
}
return {
"token": mint,
"symbol": scan.symbol,
"name": scan.name,
"safety_score": scan.safety_score,
"confidence": getattr(scan, "confidence", 0),
"risk_flags": scan.risk_flags,
"enrichments": enrich,
}
except Exception:
return None
try:
results = await asyncio.wait_for(
asyncio.gather(*[_scan_one(m) for m in mints], return_exceptions=True), timeout=15.0
)
except TimeoutError:
results = [] # Timeout - return what we have
risks = []
deployers_to_flag = []
highest = None
for r in results if isinstance(results, list) else []:
if isinstance(r, dict) and r:
risks.append(r)
if r["safety_score"] < 30:
# Flag deployer for data flywheel
enrich = r.get("enrichments", {})
deployer = enrich.get("deployer", {}) or {}
dev_addr = deployer.get("deployer") if isinstance(deployer, dict) else None
if dev_addr and r["safety_score"] < 20:
deployers_to_flag.append(
{
"deployer": dev_addr,
"token": r["token"],
"symbol": r["symbol"],
"chain": chain,
"risk_score": r["safety_score"],
"flags": r["risk_flags"][:3],
}
)
if highest is None or r["safety_score"] < highest.get("safety_score", 100):
highest = r
return {
"scanned": len(risks),
"risks": risks,
"highest_risk": highest,
"deployers_to_flag": deployers_to_flag,
"avg_safety": round(sum(r["safety_score"] for r in risks) / len(risks), 1) if risks else 0,
"high_risk_count": sum(1 for r in risks if r["safety_score"] < 30),
}
# ═══════════════════════════════════════════
# UNIFIED WALLET SCAN
# ═══════════════════════════════════════════
# ═══════════════════════════════════════════
# FREE-TIER ENHANCEMENTS
# ═══════════════════════════════════════════
async def _check_hapi_risk(address: str, chain: str) -> dict[str, Any] | None:
"""Query HAPI Protocol on-chain risk scoring.
HAPI is an on-chain cybersecurity network detecting malicious activity.
Uses both external and on-chain data for real-time AML screening.
"""
try:
# HAPI doesn't have a simple REST API - we query their public data
# or use their subgraph. For now, we check their public status endpoint.
async with httpx.AsyncClient(timeout=10.0) as client:
# HAPI Protocol public API (if available)
resp = await client.get(
f"https://hapi.one/api/v1/address/{address}",
params={"chain": chain.lower()},
)
if resp.status_code == 200:
data = resp.json()
return {
"risk_score": data.get("risk_score", 0),
"risk_level": data.get("risk_level", "unknown"),
"is_malicious": data.get("is_malicious", False),
"categories": data.get("categories", []),
"source": "hapi",
}
except Exception as e:
logger.debug(f"HAPI check failed: {e}")
return None
async def _check_misttrack_light(address: str, chain: str) -> dict[str, Any] | None:
"""Query SlowMist MistTrack Light - free wallet risk assessment.
Provides preliminary risk profiling and graph analysis.
"""
try:
# MistTrack web interface scraping or API if available
# The free tier is via their web app; we'll try their public API
async with httpx.AsyncClient(timeout=12.0) as client:
resp = await client.get(
f"https://misttrack.io/api/v1/address/{address}",
params={"chain": chain.lower()},
)
if resp.status_code == 200:
data = resp.json()
return {
"risk_score": data.get("risk_score", 0),
"risk_level": data.get("risk_level", "unknown"),
"labels": data.get("labels", []),
"related_addresses": data.get("related", [])[:5],
"source": "misttrack",
}
except Exception as e:
logger.debug(f"MistTrack check failed: {e}")
return None
async def _check_honeydb_ip(ip_address: str) -> dict[str, Any] | None:
"""Query HoneyDB for threat actor data on IP address.
Community-driven honeypot data aggregation. 1,500 req/month free.
Useful for correlating IP addresses with crypto scam infrastructure.
"""
try:
api_id = os.getenv("HONEYDB_API_ID", "") # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue
api_key = os.getenv("HONEYDB_API_KEY", "") # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue
if not api_id or not api_key:
return None
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
"https://honeydb.io/api/v1/indicator/ipv4",
params={"ip": ip_address},
headers={"X-HoneyDB-APIID": api_id, "X-HoneyDB-APIKey": api_key},
)
if resp.status_code == 200:
data = resp.json()
return {
"is_threat": data.get("is_threat", False),
"threat_score": data.get("threat_score", 0),
"events": data.get("events", []),
"source": "honeydb",
}
except Exception as e:
logger.debug(f"HoneyDB check failed: {e}")
return None
async def _check_trm_sanctions_wallet(address: str) -> dict[str, Any] | None:
"""Screen wallet address against TRM Labs sanctions list.
Free tier: 1 req/sec, 100/day unauthenticated.
Authenticated: 1K req/sec, 100K/day.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
"https://api.trmlabs.com/public/v1/sanctions/screening",
json={"address": [address]},
headers={"Content-Type": "application/json"},
)
if resp.status_code == 201:
data = resp.json()
results = data.get("results", [])
if results:
r = results[0]
return {
"is_sanctioned": r.get("isSanctioned", False),
"screening_address": r.get("address", address),
"source": "trm_labs",
"risk_level": "critical" if r.get("isSanctioned") else "low",
}
elif resp.status_code == 429:
logger.warning("TRM Labs rate limit hit")
return {"source": "trm_labs", "rate_limited": True, "is_sanctioned": False}
except Exception as e:
logger.debug(f"TRM sanctions check failed: {e}")
return None
async def _trace_funding_source(address: str, chain: str) -> dict[str, Any]:
"""Trace initial funding source using on-chain transaction history."""
try:
if chain == "solana":
key = _get_helius_key()
if not key:
return {}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"https://api.helius.xyz/v0/addresses/{address}/transactions",
params={"api-key": key, "limit": 10, "type": "TRANSFER"},
)
if resp.status_code != 200:
return {}
txs = resp.json()
if not txs:
return {"source": "unknown", "hops": 0}
# Find first incoming transfer
first_in = None
for tx in txs:
transfers = tx.get("tokenTransfers", []) + tx.get("nativeTransfers", [])
for t in transfers:
if t.get("toUserAccount") == address:
first_in = t
break
if first_in:
break
source = first_in.get("fromUserAccount", "unknown") if first_in else "unknown"
source_label = await _resolve_entity_free(source, chain)
return {
"source": source,
"source_label": source_label.get("label", "unknown"),
"is_cex": "exchange" in str(source_label).lower()
or "cex" in str(source_label).lower(),
"is_mixer": "mixer" in str(source_label).lower()
or "tornado" in str(source_label).lower(),
"hops": 1,
}
return {"source": "unknown", "hops": 0}
except Exception:
return {"source": "unknown", "hops": 0}
async def _resolve_entity_free(address: str, chain: str) -> dict[str, Any]:
"""Basic cross-chain entity resolution - FREE tier (no Wallet Memory Bank)."""
try:
# Check SENTINEL labels first
from app.domains.scanners.address_labeler import AddressLabeler
labeler = AddressLabeler()
labels = await labeler.analyze(address, chain)
if labels and labels.primary_label:
return {"label": labels.primary_label, "source": "sentinel", "chains": [chain]}
# Check wallet_labels CSV
from app.wallet_memory.labels import get_label
label = get_label(address, chain)
if label:
return {"label": label, "source": "wallet_labels", "chains": [chain]}
return {"label": "unknown", "source": "none", "chains": [chain]}
except Exception:
return {"label": "unknown", "source": "none", "chains": [chain]}
async def _calculate_basic_pnl(
address: str, chain: str, tokens: list, native_balance: float = 0
) -> dict[str, Any]:
"""Basic PnL calculation - FREE tier (no GMGN)."""
try:
total_value = native_balance or 0
token_count = len(tokens)
if tokens and chain == "solana":
# Try Jupiter price lookup for tokens
mints = [t.get("mint", "") for t in tokens[:10] if t.get("mint")]
if mints:
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(
"https://quote-api.jup.ag/v6/price", params={"ids": ",".join(mints[:5])}
)
if resp.status_code == 200:
prices = resp.json().get("data", {})
for mint, pdata in prices.items():
price = float(pdata.get("price", 0))
for t in tokens:
if t.get("mint") == mint:
balance = float(t.get("amount", 0)) / (
10 ** (t.get("decimals", 0) or 0)
)
total_value += balance * price
return {
"estimated_value_usd": round(total_value, 2),
"token_count": token_count,
"priced_tokens": min(len(tokens), 5),
"method": "jupiter" if total_value > native_balance else "native_only",
}
except Exception:
return {
"estimated_value_usd": round(native_balance, 2),
"token_count": len(tokens),
"method": "native_only",
}
async def _close_data_flywheel(deployers: list):
"""Feed scam findings back to Wallet Memory Bank."""
if not deployers:
return
try:
from app.wallet_memory.engine import get_wallet_engine
engine = get_wallet_engine()
for d in deployers[:5]: # Limit to 5
await engine.flag_deployer(
d["deployer"],
d["chain"],
reason=f"scam_token_{d['symbol']}",
token=d["token"],
score=100 - d["risk_score"],
)
logger.info(f"Data flywheel: flagged {len(deployers[:5])} deployers")
except Exception as e:
logger.warning(f"Data flywheel failed: {e}")
async def scan_wallet(
wallet_address: str, chain: str = "solana", tier: str = "free"
) -> dict[str, Any]:
"""Unified wallet scanner - SENTINEL + Helius/Moralis/Etherscan pool + Wallet Memory + Token Scanner."""
import time
# ── Redis cache check (1hr TTL, skips repeat scans) ──
cache_key = hashlib.sha256(f"{chain}:{wallet_address}:{tier}".encode()).hexdigest()[:16]
cached = await _cache_get(cache_key)
if cached:
cached["cached"] = True
logger.info(f"Wallet scan {wallet_address[:12]}... [{chain}] = CACHED")
return cached
factors = WalletRiskFactors()
start = time.time()
data_sources = []
token_list = []
modules_run = [] # Track which modules succeeded/failed
# ── On-chain data: chain-specific multi-source fetchers ──
onchain = {}
if chain == "solana":
# Solana: Helius (credential pool) + fallback to public RPC
onchain = await _fetch_helius_wallet(wallet_address)
if not onchain:
# Fallback to free public Solana RPC
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
"https://api.mainnet-beta.solana.com",
json={
"jsonrpc": "2.0",
"id": 1,
"method": "getBalance",
"params": [wallet_address],
},
)
if resp.status_code == 200:
bal = resp.json().get("result", {}).get("value", 0)
onchain = {
"sol_balance": bal / 1e9 if bal else 0,
"data_sources": ["solana_public"],
}
except Exception: # noqa: S110
pass
else:
# EVM chain: Moralis (primary) + Etherscan family (fallback)
onchain = await _fetch_moralis_wallet(wallet_address, chain)
etherscan = await _fetch_etherscan_wallet(wallet_address, chain)
if etherscan and not onchain.get("wallet_age_days"):
onchain["wallet_age_days"] = etherscan.get("wallet_age_days", 0)
onchain["data_sources"] = onchain.get("data_sources", []) + etherscan.get(
"data_sources", []
)
if onchain:
factors.wallet_age_days = onchain.get("wallet_age_days", 0)
factors.total_tx_count = onchain.get("tx_count", onchain.get("tx_count_sample", 0))
factors.current_balance_usd = onchain.get("sol_balance", onchain.get("native_balance", 0))
token_list = onchain.get("tokens", [])
factors.unique_tokens_held = onchain.get("token_count", len(token_list))
if onchain.get("last_tx"):
last_ts = onchain["last_tx"]
if isinstance(last_ts, (int, float)):
factors.days_since_last_tx = int((datetime.now(UTC).timestamp() - last_ts) / 86400)
data_sources.extend(onchain.get("data_sources", []))
# ── DexScreener volume + labels (FREE tier, all chains) ──
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(
"https://api.dexscreener.com/latest/dex/search", params={"q": wallet_address}
)
if resp.status_code == 200:
pairs = resp.json().get("pairs", [])
if pairs:
factors.total_volume_usd = sum(
float(p.get("volume", {}).get("h24", 0) or 0) for p in pairs
)
factors.dex_swaps = len(pairs)
factors.token_launches_participated = sum(
1
for p in pairs
if p.get("pairCreatedAt")
and (
datetime.now(UTC)
- datetime.fromtimestamp(p.get("pairCreatedAt", 0) / 1000, tz=UTC)
).total_seconds()
/ 3600
< 24
)
data_sources.append("dexscreener")
except Exception: # noqa: S110
pass
# ── SENTINEL address labels (6-source label resolution, FREE tier) ──
sentinel_labels = await _fetch_sentinel_labels(wallet_address, chain)
if sentinel_labels:
factors.entity_label = sentinel_labels.get("category", "")
factors.is_labeled_scammer = (
factors.is_labeled_scammer or "scam" in str(sentinel_labels).lower()
)
factors.is_labeled_sanctioned = (
factors.is_labeled_sanctioned
or "sanction" in str(sentinel_labels).lower()
or "ofac" in str(sentinel_labels).lower()
)
factors.is_labeled_exchange = (
"exchange" in str(sentinel_labels).lower() or "cex" in str(sentinel_labels).lower()
)
factors.is_labeled_bot = (
"bot" in str(sentinel_labels).lower() or "mev" in str(sentinel_labels).lower()
)
factors.is_labeled_whale = "whale" in str(sentinel_labels).lower()
factors.is_labeled_insider = "insider" in str(sentinel_labels).lower()
factors.entity_confidence = max(
factors.entity_confidence, sentinel_labels.get("risk_score", 50)
)
data_sources.append("sentinel_labels")
# ── FREE: External threat intelligence (HAPI, MistTrack, TRM sanctions) ──
hapi_risk = await _check_hapi_risk(wallet_address, chain)
if hapi_risk:
if hapi_risk.get("is_malicious"):
factors.is_labeled_scammer = True
factors.scam_token_exposure = max(factors.scam_token_exposure, 1)
data_sources.append("hapi")
misttrack = await _check_misttrack_light(wallet_address, chain)
if misttrack:
if misttrack.get("risk_score", 0) > 70:
factors.is_labeled_scammer = True
data_sources.append("misttrack")
trm = await _check_trm_sanctions_wallet(wallet_address)
if trm:
if trm.get("is_sanctioned"):
factors.is_labeled_sanctioned = True
factors.funding_from_sanctioned = True
data_sources.append("trm_labs")
# ── PRO: Wallet Memory Bank (cross-chain identity + clusters) ──
if tier in ("pro", "elite"):
wallet_memory = await _fetch_wallet_memory(wallet_address, chain)
if wallet_memory:
factors.linked_wallets_count = len(wallet_memory.get("linked_wallets", []))
factors.cluster_size = wallet_memory.get(
"linked_wallets_count", factors.linked_wallets_count
)
factors.cluster_id = wallet_memory.get("entity_id", "")
factors.cross_chain_funding = bool(wallet_memory.get("cross_chain_presence"))
scam_assoc = wallet_memory.get("scam_associations", [])
if scam_assoc:
factors.is_labeled_scammer = True
factors.scam_token_exposure = len(scam_assoc)
hist = wallet_memory.get("deployer_history", {}) or {}
factors.rug_pull_launcher_count = hist.get("rugs", 0)
data_sources.append("wallet_memory")
# GMGN scoring
try:
from app.gmgn_client import GMGNClient
gmgn = GMGNClient()
intel = await gmgn.get_wallet_intelligence(wallet_address)
if intel:
factors.insider_trading_confidence = float(intel.get("insider_score", 0))
factors.sniper_activity_score = float(intel.get("sniper_score", 0))
factors.wash_trading_score = float(intel.get("wash_trading_score", 0))
factors.pump_and_dump_participant = intel.get("pump_dump_participant", False)
factors.new_token_flipper = intel.get("flipper", False)
pnl = intel.get("pnl", {}) or {}
factors.realized_pnl_usd = float(pnl.get("realized", 0))
factors.unrealized_pnl_usd = float(pnl.get("unrealized", 0))
factors.win_rate = float(intel.get("win_rate", 0))
data_sources.append("gmgn")
except Exception: # noqa: S110
pass
# Entity clustering
try:
from app.entity_clustering import EntityClusteringEngine
engine = EntityClusteringEngine()
cluster = engine.find_cluster(wallet_address)
if cluster:
factors.cluster_size = max(factors.cluster_size, cluster.get("size", 0))
factors.cluster_shared_funding = cluster.get("shared_funding", False)
factors.known_cluster_type = cluster.get("type", "")
factors.linked_wallets_count = max(
factors.linked_wallets_count, cluster.get("member_count", 0)
)
except Exception: # noqa: S110
pass
# ── FREE: Token portfolio risk scan (parallel + enrichments) ──
token_scan = await _scan_held_tokens(token_list, chain)
if token_scan:
factors.held_token_risks = token_scan.get("risks", [])
factors.highest_risk_token = token_scan.get("highest_risk")
high_risk_count = token_scan.get("high_risk_count", 0)
factors.honeypot_tokens_owned = high_risk_count
factors.avg_held_token_safety = token_scan.get("avg_safety", 0)
# Data flywheel: flag deployers of scam tokens
deployers = token_scan.get("deployers_to_flag", [])
if deployers:
asyncio.create_task(_close_data_flywheel(deployers)) # noqa: RUF006
# ── FREE: Funding source tracing ──
funding = await _trace_funding_source(wallet_address, chain)
if funding:
factors.funding_source = funding.get("source", factors.funding_source)
factors.funding_source_label = funding.get("source_label", factors.funding_source_label)
factors.funding_source_is_cex = funding.get("is_cex", False)
factors.funding_source_is_mixer = funding.get("is_mixer", False)
factors.funding_hop_count = funding.get("hops", 0)
data_sources.append("funding_trace")
# ── FREE: Basic cross-chain identity ──
free_entity = await _resolve_entity_free(wallet_address, chain)
if free_entity and free_entity.get("label") != "unknown":
if not factors.entity_label:
factors.entity_label = free_entity["label"]
if not factors.entity_confidence:
factors.entity_confidence = 60 # free-tier confidence
data_sources.append("free_entity")
# ── FREE: Basic PnL ──
native_bal = onchain.get("sol_balance", onchain.get("native_balance", 0)) if onchain else 0
basic_pnl = await _calculate_basic_pnl(wallet_address, chain, token_list, native_bal)
if basic_pnl:
factors.current_balance_usd = max(
factors.current_balance_usd or 0, basic_pnl.get("estimated_value_usd", 0)
)
factors.realized_pnl_usd = factors.realized_pnl_usd or 0 # keep if GMGN set it
data_sources.append("jupiter_pnl")
# ── ELITE: ML anomaly + portfolio tracker ──
if tier == "elite":
# ML anomaly detection
try:
from app.ml_anomaly import detect_wallet_anomaly
anomaly = await detect_wallet_anomaly(wallet_address, chain)
if anomaly:
factors.sybil_score = anomaly.get("sybil_score", 0)
except Exception: # noqa: S110
pass
# Portfolio tracker
try:
from app.portfolio_tracker import get_portfolio
portfolio = await get_portfolio(wallet_address, chain)
if portfolio:
factors.current_balance_usd = max(
factors.current_balance_usd, float(portfolio.get("total_value_usd", 0))
)
factors.portfolio_diversity_score = portfolio.get("diversity_score", 0)
factors.stablecoin_ratio = portfolio.get("stablecoin_pct", 0)
factors.bluechip_ratio = portfolio.get("bluechip_pct", 0)
factors.memecoin_ratio = portfolio.get("memecoin_pct", 0)
except Exception: # noqa: S110
pass
# ═══════════════════════════════════════════
# Risk scoring
# ═══════════════════════════════════════════
risk = 0
flags = []
# Labels (strongest signal)
if factors.is_labeled_sanctioned:
risk += 50
flags.append("SANCTIONED")
if factors.is_labeled_scammer:
risk += 45
flags.append("KNOWN_SCAMMER")
if factors.is_labeled_insider:
risk += 25
flags.append("INSIDER_LABELED")
if factors.is_labeled_bot:
risk += 10
flags.append("BOT_WALLET")
if factors.is_labeled_exchange:
risk -= 10 # exchanges are neutral
# Funding
if factors.funding_source_is_mixer:
risk += 25
flags.append("MIXER_FUNDING")
if factors.funding_from_sanctioned:
risk += 40
flags.append("SANCTIONED_FUNDING")
if factors.tornado_cash_exposure:
risk += 35
flags.append("TORNADO_CASH")
# Age/Activity (only flag if wallet_age actually populated)
if factors.wallet_age_days > 0:
if factors.wallet_age_days < 1:
risk += 15
flags.append("FRESH_WALLET")
elif factors.wallet_age_days < 7 and factors.total_volume_usd > 100000:
risk += 20
flags.append("NEW_WALLET_HIGH_VOLUME")
elif factors.total_volume_usd > 50000:
risk += 10
flags.append("UNVERIFIED_HIGH_VOLUME")
# Held token risk (FREE tier - from parallel token scan)
if factors.honeypot_tokens_owned > 0:
risk += 25
flags.append(f"HOLDS_SCAM_TOKENS_{factors.honeypot_tokens_owned}")
if factors.avg_held_token_safety and factors.avg_held_token_safety < 40:
risk += 15
flags.append("LOW_QUALITY_PORTFOLIO")
# Token interaction
if factors.rug_pull_launcher_count > 0:
risk += 50
flags.append("RUG_PULL_LAUNCHER")
if factors.honeypot_tokens_owned > 0:
risk += 20
flags.append("HONEYPOT_HOLDER")
if factors.sniper_activity_score > 70:
risk += 20
flags.append("HIGH_SNIPER")
if factors.sandwich_attacks_executed > 0:
risk += 25
flags.append("SANDWICH_ATTACKER")
if factors.flash_loan_usage > 10:
risk += 15
flags.append("HEAVY_FLASH_LOAN")
# Behavioral
if factors.insider_trading_confidence > 70:
risk += 30
flags.append("INSIDER_TRADING")
if factors.wash_trading_score > 60:
risk += 20
flags.append("WASH_TRADING")
if factors.pump_and_dump_participant:
risk += 20
flags.append("PUMP_DUMP_PARTICIPANT")
# Cluster
if factors.cluster_size > 5 and factors.cluster_shared_funding:
risk += 20
flags.append("LARGE_SHARED_CLUSTER")
if factors.known_cluster_type in ("bot_farm", "sniper_ring"):
risk += 15
flags.append("SUSPICIOUS_CLUSTER")
# ML
if factors.sybil_score > 70:
risk += 15
flags.append("SYBIL_DETECTED")
# Token portfolio risk (ELITE only)
if factors.highest_risk_token:
ts = factors.highest_risk_token
if ts.get("safety_score", 100) < 25:
risk += 15
flags.append(f"HOLDS_CRITICAL_TOKEN_{ts.get('symbol', '???')}")
factors.total_risk_score = min(100, max(0, risk))
factors.risk_category = (
"critical"
if risk >= 80
else "high_risk"
if risk >= 60
else "medium"
if risk >= 35
else "low_risk"
if risk >= 15
else "safe"
)
# Confidence based on data quality
confidence = 15 # base
if "helius" in data_sources:
confidence += 20
if "sentinel_labels" in data_sources:
confidence += 20
if "dexscreener" in data_sources:
confidence += 10
if "wallet_memory" in data_sources:
confidence += 15
if "gmgn" in data_sources:
confidence += 15
confidence += min(len(flags) * 2, 15) # flags add some confidence
factors.confidence = min(100, confidence)
factors.data_sources = data_sources
# Build modules_run list from data_sources with status
for src in data_sources:
modules_run.append({"module": src, "status": "ok"})
# Add known modules that were attempted but failed (not in data_sources)
attempted_modules = [
"helius_wallet",
"moralis_wallet",
"etherscan_wallet",
"dexscreener",
"rugcheck",
"goplus_security",
"wallet_labels",
"wallet_memory",
"sentinel_risk",
"gmgn",
"funding_trace",
"free_entity",
"jupiter_pnl",
"entity_clustering",
]
succeeded_set = set(data_sources)
for mod in attempted_modules:
if mod not in succeeded_set:
modules_run.append({"module": mod, "status": "degraded", "error": "no_data_returned"})
elapsed = time.time() - start
logger.info(
f"Wallet scan {wallet_address[:12]}... [{chain}] = {factors.total_risk_score}/100 ({factors.risk_category}) conf={factors.confidence}% in {elapsed:.1f}s"
)
result = {
"wallet": wallet_address,
"chain": chain,
"risk_score": factors.total_risk_score,
"risk_category": factors.risk_category,
"risk_flags": flags[:20],
"total_flags": len(flags),
"confidence": factors.confidence,
"tier": tier,
"data_sources": factors.data_sources,
"factors": {
"wallet_age_days": factors.wallet_age_days,
"total_tx_count": factors.total_tx_count,
"total_volume_usd": factors.total_volume_usd,
"unique_tokens_held": factors.unique_tokens_held,
"funding_source": factors.funding_source,
"funding_source_label": factors.funding_source_label,
"funding_source_is_cex": factors.funding_source_is_cex,
"funding_source_is_mixer": factors.funding_source_is_mixer,
"funding_from_sanctioned": factors.funding_from_sanctioned,
"funding_from_scammer": factors.funding_from_scammer,
"is_labeled_scammer": factors.is_labeled_scammer,
"is_labeled_sanctioned": factors.is_labeled_sanctioned,
"is_labeled_exchange": factors.is_labeled_exchange,
"is_labeled_bot": factors.is_labeled_bot,
"is_labeled_whale": factors.is_labeled_whale,
"is_labeled_insider": factors.is_labeled_insider,
"entity_label": factors.entity_label,
"entity_confidence": factors.entity_confidence,
"cluster_size": factors.cluster_size,
"cluster_shared_funding": factors.cluster_shared_funding,
"known_cluster_type": factors.known_cluster_type,
"linked_wallets_count": factors.linked_wallets_count,
"cross_chain_funding": factors.cross_chain_funding,
"tornado_cash_exposure": factors.tornado_cash_exposure,
"sniper_activity_score": factors.sniper_activity_score,
"insider_trading_confidence": factors.insider_trading_confidence,
"wash_trading_score": factors.wash_trading_score,
"pump_and_dump_participant": factors.pump_and_dump_participant,
"sybil_score": factors.sybil_score,
"rug_pull_launcher_count": factors.rug_pull_launcher_count,
"honeypot_tokens_owned": factors.honeypot_tokens_owned,
"sandwich_attacks_executed": factors.sandwich_attacks_executed,
"flash_loan_usage": factors.flash_loan_usage,
"realized_pnl_usd": factors.realized_pnl_usd,
"unrealized_pnl_usd": factors.unrealized_pnl_usd,
"win_rate": factors.win_rate,
"current_balance_usd": factors.current_balance_usd,
"portfolio_diversity_score": factors.portfolio_diversity_score,
"stablecoin_ratio": factors.stablecoin_ratio,
"bluechip_ratio": factors.bluechip_ratio,
"memecoin_ratio": factors.memecoin_ratio,
"dex_swaps": factors.dex_swaps,
"cex_deposits": factors.cex_deposits,
"cex_withdrawals": factors.cex_withdrawals,
"token_launches_participated": factors.token_launches_participated,
},
"held_tokens": {
"scanned": len(factors.held_token_risks),
"highest_risk": factors.highest_risk_token,
"risks": factors.held_token_risks[:10],
},
"modules_run": modules_run,
"scanned_at": datetime.now(UTC).isoformat(),
"tool_fingerprints": None,
}
# Cache result for 1hr
asyncio.create_task(_cache_set(cache_key, result, ttl=3600)) # noqa: RUF006
return result
# ═══════════════════════════════════════════════════════════════
# TRENDING ENGINE - Multi-source, real-time
# ═══════════════════════════════════════════════════════════════
async def get_trending_tokens(chain: str | None = None, limit: int = 20) -> dict[str, Any]:
"""Get trending tokens from multiple sources, normalized across chains."""
import time
start = time.time()
trending = []
sources_used = []
async with httpx.AsyncClient(timeout=15.0) as client:
try:
url = "https://api.dexscreener.com/latest/dex/search"
params = {"q": chain or "SOL", "limit": limit}
resp = await client.get(url, params=params)
if resp.status_code == 200:
pairs = resp.json().get("pairs", [])
now = datetime.now(UTC)
for pair in pairs:
created = pair.get("pairCreatedAt")
liq = float(pair.get("liquidity", {}).get("usd", 0))
vol = float(pair.get("volume", {}).get("h24", 0))
if liq < 1000:
continue
age_hours = 999
if created:
age_hours = (
now - datetime.fromtimestamp(created / 1000, tz=UTC)
).total_seconds() / 3600
trending_score = (vol * 0.6 + liq * 0.3) / max(age_hours, 1)
trending.append(
{
"chain": pair.get("chainId", chain or "unknown"),
"address": pair.get("baseToken", {}).get("address", ""),
"symbol": pair.get("baseToken", {}).get("symbol", ""),
"name": pair.get("baseToken", {}).get("name", ""),
"price_usd": float(pair.get("priceUsd", 0)),
"liquidity_usd": liq,
"volume_24h": vol,
"price_change_24h": float(pair.get("priceChange", {}).get("h24", 0)),
"age_hours": round(age_hours, 1),
"trending_score": round(trending_score, 2),
"dex": pair.get("dexId", ""),
"source": "dexscreener",
}
)
sources_used.append("dexscreener")
except Exception as e:
logger.warning(f"DexScreener trending failed: {e}")
try:
network = "solana" if chain == "solana" else "eth"
resp = await client.get(
f"https://api.geckoterminal.com/api/v2/networks/{network}/trending_pools",
params={"limit": limit},
)
if resp.status_code == 200:
pools = resp.json().get("data", [])
for pool in pools:
attrs = pool.get("attributes", {})
trending.append(
{
"chain": chain or "unknown",
"address": attrs.get("base_token_address", ""),
"symbol": attrs.get("base_token_symbol", ""),
"name": attrs.get("name", ""),
"price_usd": float(attrs.get("base_token_price_usd", 0)),
"liquidity_usd": float(attrs.get("reserve_in_usd", 0)),
"volume_24h": float(attrs.get("volume_usd", {}).get("h24", 0)),
"price_change_24h": float(
attrs.get("price_change_percentage", {}).get("h24", 0)
),
"source": "geckoterminal",
}
)
sources_used.append("geckoterminal")
except Exception: # noqa: S110
pass
try:
resp = await client.get("https://api.coingecko.com/api/v3/search/trending")
if resp.status_code == 200:
coins = resp.json().get("coins", [])
for item in coins[:limit]:
coin = item.get("item", {})
trending.append(
{
"chain": chain or "multi",
"address": coin.get("id", ""),
"symbol": coin.get("symbol", "").upper(),
"name": coin.get("name", ""),
"price_usd": coin.get("data", {}).get("price", 0),
"market_cap_rank": coin.get("market_cap_rank"),
"trending_score": coin.get("score", 0),
"source": "coingecko",
}
)
sources_used.append("coingecko")
except Exception: # noqa: S110
pass
seen = set()
unique = []
for t in trending:
key = f"{t.get('chain')}:{t.get('address')}"
if key not in seen:
seen.add(key)
unique.append(t)
unique.sort(key=lambda x: x.get("trending_score", 0), reverse=True)
elapsed = time.time() - start
logger.info(f"Trending: {len(unique)} tokens from {sources_used} in {elapsed:.1f}s")
return {
"tokens": unique[:limit],
"total": len(unique),
"sources": sources_used,
"chains_covered": list({t.get("chain", "?") for t in unique}),
"updated_at": datetime.now(UTC).isoformat(),
}
# ═══════════════════════════════════════════════════════════════
# REAL-TIME LAUNCH MONITOR
# ═══════════════════════════════════════════════════════════════
LAST_KNOWN_LAUNCHES: dict[str, set] = {}
async def get_new_launches(chain: str = "solana", since_seconds: int = 300) -> dict[str, Any]:
"""Get tokens launched in the last N seconds."""
import time
start = time.time()
now = datetime.now(UTC)
cutoff = (now.timestamp() * 1000) - (since_seconds * 1000)
launches = []
async with httpx.AsyncClient(timeout=10.0) as client:
try:
resp = await client.get(
"https://api.dexscreener.com/latest/dex/search", params={"q": chain, "limit": 50}
)
if resp.status_code == 200:
pairs = resp.json().get("pairs", [])
for pair in pairs:
created = pair.get("pairCreatedAt", 0)
if created and created > cutoff:
liq = float(pair.get("liquidity", {}).get("usd", 0))
if liq < 500:
continue
launches.append(
{
"chain": pair.get("chainId", chain),
"address": pair.get("baseToken", {}).get("address", ""),
"symbol": pair.get("baseToken", {}).get("symbol", ""),
"name": pair.get("baseToken", {}).get("name", ""),
"price_usd": float(pair.get("priceUsd", 0)),
"liquidity_usd": liq,
"age_seconds": int((now.timestamp() * 1000 - created) / 1000),
"dex": pair.get("dexId", ""),
}
)
except Exception: # noqa: S110
pass
chain_key = chain or "all"
if chain_key not in LAST_KNOWN_LAUNCHES:
LAST_KNOWN_LAUNCHES[chain_key] = set()
known = LAST_KNOWN_LAUNCHES[chain_key]
new_launches = [line for line in launches if line["address"] not in known]
for line in new_launches:
known.add(line["address"])
# Keep set bounded
if len(known) > 10000:
LAST_KNOWN_LAUNCHES[chain_key] = set(list(known)[-5000:])
elapsed = time.time() - start
logger.info(f"New launches [{chain}]: {len(new_launches)} in {elapsed:.1f}s")
return {
"launches": new_launches,
"total_new": len(new_launches),
"total_known": len(known),
"chain": chain,
"since_seconds": since_seconds,
"updated_at": datetime.now(UTC).isoformat(),
}