- Make app/domains/auth/ and app/core/redis.py mypy-clean under strict. - Add mypy-gate.ini and Makefile mypy-gate target; promote typecheck-gate in CI. - Consolidate domains into app/domains/: bulletin, admin, intelligence, markets. - Extract referral domain incl. DeFi partner DEX ref links; keep Telegram bot wired. - Move app/mcp/ package and app/api/v1/mcp/router into app/domains/mcp/. - Archive dead app/mcp_router.py.
1217 lines
44 KiB
Python
1217 lines
44 KiB
Python
"""
|
|
DataBus Provider Implementations - External API Interfaces
|
|
===========================================================
|
|
|
|
All external API implementations for the DataBus providers.
|
|
This module contains NO chain definitions or infrastructure.
|
|
"""
|
|
|
|
import asyncio
|
|
import datetime
|
|
import os
|
|
|
|
import httpx
|
|
|
|
from sdks.python.x402_frameworks.autogen_adapter import logger
|
|
|
|
|
|
async def _local_token_price(**kwargs) -> dict | None:
|
|
"""Get price from our own data (Redis/ClickHouse)."""
|
|
try:
|
|
import json
|
|
import os
|
|
|
|
import redis
|
|
|
|
r = redis.Redis(
|
|
host=os.getenv("REDIS_HOST", "rmi-redis"),
|
|
port=int(os.getenv("REDIS_PORT", "6379")),
|
|
password=os.getenv("REDIS_PASSWORD", ""),
|
|
decode_responses=True,
|
|
socket_connect_timeout=2,
|
|
)
|
|
token = kwargs.get("token", "")
|
|
if token:
|
|
cached = r.get(f"price:{token.lower()}")
|
|
if cached:
|
|
return json.loads(cached)
|
|
r.close()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _dexscreener_price(**kwargs) -> dict | None:
|
|
"""DexScreener - free, no key."""
|
|
token = kwargs.get("mint", "") or kwargs.get("token", "")
|
|
if not token:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(f"https://api.dexscreener.com/latest/dex/tokens/{token}")
|
|
if r.status_code == 200:
|
|
return r.json()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _coingecko_price(**kwargs) -> dict | None:
|
|
"""CoinGecko - free for low volume."""
|
|
token = kwargs.get("mint", "") or kwargs.get("token", "")
|
|
api_key = kwargs.get("api_key", "")
|
|
try:
|
|
headers = {"x-cg-pro-api-key": api_key} if api_key else {}
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(f"https://api.coingecko.com/api/v3/simple/token_price/{token}", headers=headers)
|
|
if r.status_code == 200:
|
|
return r.json()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _moralis_price(**kwargs) -> dict | None:
|
|
"""Moralis - paid, high quality."""
|
|
token = kwargs.get("token", "")
|
|
api_key = kwargs.get("api_key", "")
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(
|
|
f"https://deep-index.moralis.io/api/v2/erc20/{token}/price",
|
|
headers={"X-API-Key": api_key},
|
|
)
|
|
if r.status_code == 200:
|
|
return r.json()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── ALCHEMY PROVIDER ──────────────────────────────────────────
|
|
|
|
|
|
async def _alchemy_token_balances(address: str = "", network: str = "eth-mainnet", **kw) -> dict | None:
|
|
"""Alchemy - get all token balances for an address."""
|
|
api_key = kw.get("api_key", "")
|
|
if not address or not api_key:
|
|
return None
|
|
try:
|
|
url = f"https://{network}.g.alchemy.com/v2/{api_key}"
|
|
payload = {
|
|
"jsonrpc": "2.0",
|
|
"method": "alchemy_getTokenBalances",
|
|
"params": [address, "erc20"],
|
|
"id": 1,
|
|
}
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.post(url, json=payload)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
if "result" in data:
|
|
return {"balances": data["result"], "address": address, "network": network}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _alchemy_token_metadata(contract: str = "", network: str = "eth-mainnet", **kw) -> dict | None:
|
|
"""Alchemy - get token metadata."""
|
|
api_key = kw.get("api_key", "")
|
|
if not contract or not api_key:
|
|
return None
|
|
try:
|
|
url = f"https://{network}.g.alchemy.com/v2/{api_key}"
|
|
payload = {
|
|
"jsonrpc": "2.0",
|
|
"method": "alchemy_getTokenMetadata",
|
|
"params": [contract],
|
|
"id": 1,
|
|
}
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.post(url, json=payload)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
if "result" in data:
|
|
return {"metadata": data["result"], "contract": contract}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── PASSTHROUGH PROVIDERS - call our own backend endpoints ──────
|
|
|
|
|
|
async def _passthrough_market_overview(**kwargs) -> dict | None:
|
|
"""Market overview from our own /api/v1/content/market-overview."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get("http://localhost:8000/api/v1/content/market-overview")
|
|
if r.status_code == 200:
|
|
return r.json()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _passthrough_trending(**kwargs) -> dict | None:
|
|
"""Trending tokens from our own /api/v1/tokens/trending."""
|
|
try:
|
|
limit = kwargs.get("limit", 20)
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(f"http://localhost:8000/api/v1/tokens/trending?limit={limit}")
|
|
if r.status_code == 200:
|
|
return r.json()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _passthrough_news(**kwargs) -> dict | None:
|
|
"""News from our own 30-source aggregator."""
|
|
try:
|
|
limit = kwargs.get("limit", 20)
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(f"http://localhost:8000/api/v1/news/combined?limit={limit}")
|
|
if r.status_code == 200:
|
|
return r.json()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _passthrough_alerts(**kwargs) -> dict | None:
|
|
"""Alerts from our real alert pipeline."""
|
|
try:
|
|
from app.domains.intelligence.alert_pipeline import (
|
|
get_active_alert_count,
|
|
get_recent_alerts,
|
|
)
|
|
|
|
count = await get_active_alert_count()
|
|
recent = await get_recent_alerts(limit=kwargs.get("limit", 20))
|
|
return {"count": count, "alerts": recent}
|
|
except Exception:
|
|
pass
|
|
return {"count": 0, "alerts": []}
|
|
|
|
|
|
# ── LOCAL DATA PROVIDERS - our own databases ───────────────────
|
|
|
|
|
|
async def _local_wallet_labels(address: str = "", **kw) -> dict | None:
|
|
"""Our 190K wallet labels from Redis - rmi:label:{chain}:{address} format."""
|
|
if not address:
|
|
return None
|
|
try:
|
|
import json
|
|
import os
|
|
|
|
import redis
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv("/app/.env", override=True)
|
|
r = redis.Redis(
|
|
host=os.getenv("REDIS_HOST", "rmi-redis"),
|
|
port=int(os.getenv("REDIS_PORT", "6379")),
|
|
password=os.getenv("REDIS_PASSWORD", ""),
|
|
decode_responses=True,
|
|
socket_connect_timeout=2,
|
|
)
|
|
# Search across all chains - label key is rmi:label:{chain}:{address}
|
|
chains = [
|
|
"solana",
|
|
"ethereum",
|
|
"bsc",
|
|
"base",
|
|
"arbitrum",
|
|
"optimism",
|
|
"polygon",
|
|
"avalanche",
|
|
]
|
|
for chain in chains:
|
|
key = f"rmi:label:{chain}:{address}"
|
|
data = r.get(key)
|
|
if data:
|
|
label = json.loads(data)
|
|
r.close()
|
|
return {
|
|
"labels": [label],
|
|
"address": address,
|
|
"source": "wallet_memory_bank",
|
|
"total_labels": "190K+",
|
|
}
|
|
# Try prefix search as fallback
|
|
keys = r.keys(f"rmi:label:*:{address}")
|
|
if keys:
|
|
data = r.get(keys[0])
|
|
if data:
|
|
label = json.loads(data)
|
|
r.close()
|
|
return {
|
|
"labels": [label],
|
|
"address": address,
|
|
"source": "wallet_memory_bank",
|
|
"total_labels": "190K+",
|
|
}
|
|
r.close()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _passthrough_scanner(address: str = "", chain: str = "solana", **kw) -> dict | None:
|
|
"""SENTINEL scanner - our own token security analysis."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30) as c:
|
|
r = await c.post("http://localhost:8000/api/v1/token/scan", json={"address": address, "chain": chain})
|
|
if r.status_code == 200:
|
|
return r.json()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _passthrough_rag(query: str = "", collection: str = "known_scams", **kw) -> dict | None:
|
|
"""RAG search - our 17K+ document knowledge base."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.post(
|
|
"http://localhost:8000/api/v1/rag/search",
|
|
json={"query": query, "collection": collection, "limit": kw.get("limit", 10)},
|
|
)
|
|
if r.status_code == 200:
|
|
return r.json()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── MORALIS WEB3 AI AGENTS - Full API Suite ──────────────────────
|
|
|
|
_MORALIS_BASE = "https://deep-index.moralis.io/api/v2.2"
|
|
|
|
|
|
async def _moralis_wallet_tokens(address: str = "", chain: str = "eth", **kw) -> dict | None:
|
|
"""Moralis - get wallet token balances with metadata."""
|
|
api_key = kw.get("api_key", "")
|
|
if not address or not api_key:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(
|
|
f"{_MORALIS_BASE}/{address}/erc20",
|
|
params={"chain": chain},
|
|
headers={"X-API-Key": api_key},
|
|
)
|
|
if r.status_code == 200:
|
|
return {"tokens": r.json(), "address": address, "chain": chain}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _moralis_wallet_nfts(address: str = "", chain: str = "eth", **kw) -> dict | None:
|
|
"""Moralis - get wallet NFTs."""
|
|
api_key = kw.get("api_key", "")
|
|
if not address or not api_key:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(
|
|
f"{_MORALIS_BASE}/{address}/nft",
|
|
params={"chain": chain},
|
|
headers={"X-API-Key": api_key},
|
|
)
|
|
if r.status_code == 200:
|
|
return {"nfts": r.json(), "address": address, "chain": chain}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _moralis_wallet_transactions(address: str = "", chain: str = "eth", **kw) -> dict | None:
|
|
"""Moralis - get wallet transaction history."""
|
|
api_key = kw.get("api_key", "")
|
|
if not address or not api_key:
|
|
return None
|
|
try:
|
|
limit = kw.get("limit", 50)
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(
|
|
f"{_MORALIS_BASE}/{address}",
|
|
params={"chain": chain, "limit": limit},
|
|
headers={"X-API-Key": api_key},
|
|
)
|
|
if r.status_code == 200:
|
|
return {"transactions": r.json(), "address": address, "chain": chain}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _moralis_token_price(address: str = "", chain: str = "eth", **kw) -> dict | None:
|
|
"""Moralis - get token price via Token API."""
|
|
api_key = kw.get("api_key", "")
|
|
if not address or not api_key:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(
|
|
f"{_MORALIS_BASE}/erc20/{address}/price",
|
|
params={"chain": chain},
|
|
headers={"X-API-Key": api_key},
|
|
)
|
|
if r.status_code == 200:
|
|
return {"price": r.json(), "address": address, "chain": chain}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _moralis_token_metadata(address: str = "", chain: str = "eth", **kw) -> dict | None:
|
|
"""Moralis - get token metadata."""
|
|
api_key = kw.get("api_key", "")
|
|
if not address or not api_key:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(
|
|
f"{_MORALIS_BASE}/erc20/{address}",
|
|
params={"chain": chain},
|
|
headers={"X-API-Key": api_key},
|
|
)
|
|
if r.status_code == 200:
|
|
return {"metadata": r.json(), "address": address}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _moralis_wallet_net_worth(address: str = "", **kw) -> dict | None:
|
|
"""Moralis - wallet net worth across chains."""
|
|
api_key = kw.get("api_key", "")
|
|
if not address or not api_key:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient(timeout=20) as c:
|
|
r = await c.get(f"{_MORALIS_BASE}/wallets/{address}/net-worth", headers={"X-API-Key": api_key})
|
|
if r.status_code == 200:
|
|
return {"net_worth": r.json(), "address": address}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _moralis_search_tokens(query: str = "", **kw) -> dict | None:
|
|
"""Moralis - search tokens by name/symbol/address."""
|
|
api_key = kw.get("api_key", "")
|
|
if not query or not api_key:
|
|
return None
|
|
try:
|
|
limit = kw.get("limit", 10)
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(
|
|
f"{_MORALIS_BASE}/search",
|
|
params={"q": query, "filter": "token", "limit": limit},
|
|
headers={"X-API-Key": api_key},
|
|
)
|
|
if r.status_code == 200:
|
|
return {"results": r.json(), "query": query}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── MCP BRIDGE - Call local MCP servers from DataBus ──────────────
|
|
|
|
|
|
async def _mcp_bridge(mcp_server: str = "", mcp_tool: str = "", **kw) -> dict | None:
|
|
"""Universal MCP bridge - calls any local MCP server tool.
|
|
|
|
Uses subprocess to call MCP servers installed in /root/.hermes/mcp-servers/.
|
|
Falls back to HTTP for configured HTTP MCP servers.
|
|
"""
|
|
if not mcp_server or not mcp_tool:
|
|
return None
|
|
try:
|
|
import json as _json
|
|
import subprocess
|
|
|
|
# Build tool args dict from kwargs (strip internal params)
|
|
tool_args = {k: v for k, v in kw.items() if k not in ("api_key", "mcp_server", "mcp_tool")}
|
|
|
|
# Known MCP server → command mapping
|
|
MCP_COMMANDS = {
|
|
"evm-direct": ["node", "/root/.hermes/mcp-servers/evm-direct/bin/cli.js"],
|
|
"evmscope": ["node", "/root/.hermes/mcp-servers/evmscope/dist/cli.js"],
|
|
"jupiter-mcp": ["node", "/root/.hermes/mcp-servers/jupiter-mcp/index.js"],
|
|
"crypto-feargreed-mcp": [
|
|
"python3",
|
|
"/root/.hermes/mcp-servers/crypto-feargreed-mcp/main.py",
|
|
],
|
|
"crypto-indicators-mcp": [
|
|
"node",
|
|
"/root/.hermes/mcp-servers/crypto-indicators-mcp/index.js",
|
|
],
|
|
"moralis-mcp": ["node", "/root/.hermes/mcp-servers/moralis-mcp/src/index.mjs"],
|
|
"web3-research-mcp": ["node", "/root/.hermes/mcp-servers/web3-research-mcp/bin/cli.js"],
|
|
"solana-mcp": ["node", "/root/.hermes/mcp-servers/solana-mcp-official/index.js"],
|
|
}
|
|
|
|
if mcp_server in MCP_COMMANDS:
|
|
# Build MCP JSON-RPC call
|
|
mcp_request = _json.dumps(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "tools/call",
|
|
"params": {"name": mcp_tool, "arguments": tool_args},
|
|
"id": 1,
|
|
}
|
|
)
|
|
cmd = MCP_COMMANDS[mcp_server]
|
|
result = subprocess.run(cmd, input=mcp_request, capture_output=True, text=True, timeout=30)
|
|
if result.returncode == 0 and result.stdout:
|
|
data = _json.loads(result.stdout)
|
|
if "result" in data:
|
|
return {"result": data["result"], "server": mcp_server, "tool": mcp_tool}
|
|
else:
|
|
# Try HTTP MCP server
|
|
mcp_configs = {
|
|
"coingecko": "https://mcp.api.coingecko.com/mcp",
|
|
}
|
|
if mcp_server in mcp_configs:
|
|
async with httpx.AsyncClient(timeout=30) as c:
|
|
r = await c.post(
|
|
mcp_configs[mcp_server],
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"method": "tools/call",
|
|
"params": {"name": mcp_tool, "arguments": tool_args},
|
|
"id": 1,
|
|
},
|
|
)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
if "result" in data:
|
|
return {
|
|
"result": data["result"],
|
|
"server": mcp_server,
|
|
"tool": mcp_tool,
|
|
}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── ARKHAM INTELLIGENCE - Free trial month, premium entity data ──
|
|
|
|
_ARKHAM_BASE = "https://api.arkhamintelligence.com"
|
|
|
|
|
|
async def _arkham_entity(address: str = "", **kw) -> dict | None:
|
|
"""Arkham - entity intelligence (tx counts, top counterparties, tags)."""
|
|
api_key = kw.get("api_key", "")
|
|
if not address or not api_key:
|
|
return None
|
|
try:
|
|
headers = {"API-Key": api_key}
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(f"{_ARKHAM_BASE}/intelligence/address/{address}", headers=headers)
|
|
if r.status_code == 200:
|
|
return {"entity": r.json(), "address": address, "source": "arkham"}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _arkham_counterparties(address: str = "", **kw) -> dict | None:
|
|
"""Arkham - top counterparties ranked by transaction volume."""
|
|
api_key = kw.get("api_key", "")
|
|
if not address or not api_key:
|
|
return None
|
|
try:
|
|
limit = kw.get("limit", 25)
|
|
headers = {"API-Key": api_key}
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(
|
|
f"{_ARKHAM_BASE}/counterparties/address/{address}",
|
|
params={"limit": limit},
|
|
headers=headers,
|
|
)
|
|
if r.status_code == 200:
|
|
return {"counterparties": r.json(), "address": address, "source": "arkham"}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _arkham_intel_search(query: str = "", **kw) -> dict | None:
|
|
"""Arkham - search entities by address prefix (up to 20 matches)."""
|
|
api_key = kw.get("api_key", "")
|
|
if not query or not api_key:
|
|
return None
|
|
try:
|
|
headers = {"API-Key": api_key}
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(f"{_ARKHAM_BASE}/intelligence/search", params={"query": query}, headers=headers)
|
|
if r.status_code == 200:
|
|
return {"results": r.json(), "query": query, "source": "arkham"}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _arkham_portfolio(address: str = "", **kw) -> dict | None:
|
|
"""Arkham - portfolio via entity intelligence (includes balance info)."""
|
|
api_key = kw.get("api_key", "")
|
|
if not address or not api_key:
|
|
return None
|
|
try:
|
|
headers = {"API-Key": api_key}
|
|
async with httpx.AsyncClient(timeout=20) as c:
|
|
r = await c.get(f"{_ARKHAM_BASE}/intelligence/address/{address}", headers=headers)
|
|
if r.status_code == 200:
|
|
return {"portfolio": r.json(), "address": address, "source": "arkham"}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _arkham_transfers(address: str = "", **kw) -> dict | None:
|
|
"""Arkham - transfer history via counterparties endpoint."""
|
|
api_key = kw.get("api_key", "")
|
|
if not address or not api_key:
|
|
return None
|
|
try:
|
|
limit = kw.get("limit", 100)
|
|
headers = {"API-Key": api_key}
|
|
async with httpx.AsyncClient(timeout=20) as c:
|
|
r = await c.get(
|
|
f"{_ARKHAM_BASE}/counterparties/address/{address}",
|
|
params={"limit": limit},
|
|
headers=headers,
|
|
)
|
|
if r.status_code == 200:
|
|
return {"transfers": r.json(), "address": address, "source": "arkham"}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _arkham_labels(address: str = "", **kw) -> dict | None:
|
|
"""Arkham - labels extracted from entity intelligence (arkhamLabel field)."""
|
|
api_key = kw.get("api_key", "")
|
|
if not address or not api_key:
|
|
return None
|
|
try:
|
|
headers = {"API-Key": api_key}
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(f"{_ARKHAM_BASE}/intelligence/address/{address}", headers=headers)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
label = data.get("arkhamLabel", {})
|
|
entity = data.get("arkhamEntity", {})
|
|
labels = []
|
|
if label and label.get("name"):
|
|
labels.append({"name": label["name"], "id": label.get("id"), "type": "arkham"})
|
|
if entity and entity.get("name"):
|
|
labels.append({"name": entity["name"], "id": entity.get("id"), "type": "entity"})
|
|
return {
|
|
"labels": labels,
|
|
"address": address,
|
|
"chain": data.get("chain"),
|
|
"source": "arkham",
|
|
}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── FREE FALLBACK PROVIDERS - never pay for what's free ──────────
|
|
|
|
|
|
async def _dexscreener_token_metadata(mint: str = "", **kw) -> dict | None:
|
|
"""DexScreener - free token metadata from pairs endpoint."""
|
|
token = mint or kw.get("token", "") or kw.get("contract", "")
|
|
if not token:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(f"https://api.dexscreener.com/latest/dex/tokens/{token}")
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
pairs = data.get("pairs", [])
|
|
if pairs:
|
|
p = pairs[0]
|
|
return {
|
|
"metadata": {
|
|
"name": p.get("baseToken", {}).get("name", ""),
|
|
"symbol": p.get("baseToken", {}).get("symbol", ""),
|
|
"decimals": None,
|
|
"price_usd": p.get("priceUsd"),
|
|
"liquidity_usd": p.get("liquidity", {}).get("usd"),
|
|
"fdv": p.get("fdv"),
|
|
"chain": p.get("chainId", ""),
|
|
},
|
|
"source": "dexscreener",
|
|
}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _dexscreener_holders(mint: str = "", **kw) -> dict | None:
|
|
"""DexScreener - free holder/liquidity data from pairs."""
|
|
token = mint or kw.get("token", "")
|
|
if not token:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(f"https://api.dexscreener.com/latest/dex/tokens/{token}")
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
pairs = data.get("pairs", [])
|
|
if pairs:
|
|
return {"pairs": pairs, "total_pairs": len(pairs), "source": "dexscreener"}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _dexscreener_trades(token: str = "", chain: str = "solana", **kw) -> dict | None:
|
|
"""DexScreener - recent trades for a token (free, no API key required)."""
|
|
if not token:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(f"https://api.dexscreener.com/latest/dex/tokens/{token}")
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
pairs = data.get("pairs", [])
|
|
if pairs:
|
|
# DexScreener doesn't have a direct trades endpoint, but we can simulate
|
|
# recent activity from pair data or use a mock structure for the frontend
|
|
# to render until a dedicated trades API is wired.
|
|
return {
|
|
"trades": [],
|
|
"message": "Live trades feed requires premium DEX API. Showing pair summary.",
|
|
"pairs": pairs[:5],
|
|
"source": "dexscreener",
|
|
}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _dexscreener_top_traders(token: str = "", chain: str = "solana", **kw) -> dict | None:
|
|
"""DexScreener - top profitable traders for a token."""
|
|
if not token:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(f"https://api.dexscreener.com/latest/dex/tokens/{token}")
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
pairs = data.get("pairs", [])
|
|
if pairs:
|
|
return {
|
|
"top_traders": [],
|
|
"message": "Top trader analytics require premium on-chain indexer. Showing pair summary.",
|
|
"pairs": pairs[:3],
|
|
"source": "dexscreener",
|
|
}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _etherscan_tx_trace(tx_hash: str = "", network: str = "ethereum", **kw) -> dict | None:
|
|
"""Etherscan - free transaction data (5 req/sec, no key needed for basic)."""
|
|
if not tx_hash:
|
|
return None
|
|
try:
|
|
# Map network to Etherscan domain
|
|
domains = {
|
|
"ethereum": "api.etherscan.io",
|
|
"eth-mainnet": "api.etherscan.io",
|
|
"bsc": "api.bscscan.com",
|
|
"polygon": "api.polygonscan.com",
|
|
"arbitrum": "api.arbiscan.io",
|
|
"optimism": "api-optimistic.etherscan.io",
|
|
"base": "api.basescan.org",
|
|
}
|
|
domain = domains.get(network, "api.etherscan.io")
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(
|
|
f"https://{domain}/api",
|
|
params={
|
|
"module": "proxy",
|
|
"action": "eth_getTransactionByHash",
|
|
"txhash": tx_hash,
|
|
"apikey": "YourApiKeyToken",
|
|
},
|
|
)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
if data.get("result"):
|
|
return {
|
|
"transaction": data["result"],
|
|
"tx_hash": tx_hash,
|
|
"source": "etherscan",
|
|
}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── DEFILLAMA PROVIDER (100% FREE, NO API KEY) ────────────────────
|
|
|
|
|
|
async def _defillama_tvl(**kw) -> dict | None:
|
|
"""DeFiLlama - global TVL and protocol data (completely free)."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get("https://api.llama.fi/protocols")
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
total_tvl = sum(p.get("tvl", 0) for p in data if isinstance(p, dict))
|
|
return {
|
|
"total_tvl": total_tvl,
|
|
"protocols_count": len(data),
|
|
"top_protocols": data[:10],
|
|
"source": "defillama",
|
|
}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _defillama_chains(**kw) -> dict | None:
|
|
"""DeFiLlama - TVL by chain (completely free)."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get("https://api.llama.fi/chains")
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
return {
|
|
"chains": [{"name": c.get("name"), "tvl": c.get("tvl")} for c in data if isinstance(c, dict)],
|
|
"source": "defillama",
|
|
}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── BLOCKCHAIR PROVIDER (FREE TIER, NO API KEY FOR BASIC) ────────
|
|
|
|
|
|
async def _blockchair_address(address: str = "", chain: str = "bitcoin", **kw) -> dict | None:
|
|
"""Blockchair - address balance and tx count (free tier)."""
|
|
if not address:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(f"https://api.blockchair.com/{chain}/dashboards/address/{address}")
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
if data.get("data") and address in data["data"]:
|
|
return {
|
|
"address": address,
|
|
"chain": chain,
|
|
"data": data["data"][address],
|
|
"source": "blockchair",
|
|
}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _blockchair_stats(chain: str = "bitcoin", **kw) -> dict | None:
|
|
"""Blockchair - chain statistics (free tier)."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(f"https://api.blockchair.com/{chain}/stats")
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
return {
|
|
"chain": chain,
|
|
"stats": data.get("data", {}).get("stats", {}),
|
|
"source": "blockchair",
|
|
}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── BIRDEYE PROVIDER (FREEMIUM, FREE TIER AVAILABLE) ─────────────
|
|
|
|
|
|
async def _birdeye_overview(address: str = "", **kw) -> dict | None:
|
|
"""Birdeye - token overview, liquidity, and holder stats (free tier)."""
|
|
api_key = kw.get("api_key", "") or os.getenv("BIRDEYE_API_KEY", "")
|
|
if not address:
|
|
return None
|
|
try:
|
|
headers = {"X-API-KEY": api_key, "accept": "application/json"} if api_key else {"accept": "application/json"}
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(
|
|
f"https://public-api.birdeye.so/defi/token_overview?address={address}",
|
|
headers=headers,
|
|
)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
return {"address": address, "data": data.get("data", {}), "source": "birdeye"}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _birdeye_price(address: str = "", **kw) -> dict | None:
|
|
"""Birdeye - real-time token price (free tier)."""
|
|
api_key = kw.get("api_key", "") or os.getenv("BIRDEYE_API_KEY", "")
|
|
if not address:
|
|
return None
|
|
try:
|
|
headers = {"X-API-KEY": api_key, "accept": "application/json"} if api_key else {"accept": "application/json"}
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(f"https://public-api.birdeye.so/defi/price?address={address}", headers=headers)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
return {
|
|
"address": address,
|
|
"price": data.get("data", {}).get("value"),
|
|
"source": "birdeye",
|
|
}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── SOLANA TRACKER PROVIDER (FREEMIUM, 5K/MO FREE QUOTA) ─────────
|
|
|
|
|
|
async def _solana_tracker_price(mint: str = "", **kw) -> dict | None:
|
|
"""Solana Tracker - real-time token price (2 keys, 5000 req/mo total)."""
|
|
if not mint:
|
|
return None
|
|
try:
|
|
from app.caching_shield.solana_tracker import get_solana_tracker
|
|
|
|
st = get_solana_tracker()
|
|
data = await st.get_price(mint)
|
|
if data:
|
|
return {"mint": mint, "price": data, "source": "solana_tracker"}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _solana_tracker_token(mint: str = "", **kw) -> dict | None:
|
|
"""Solana Tracker - detailed token metadata and stats."""
|
|
if not mint:
|
|
return None
|
|
try:
|
|
from app.caching_shield.solana_tracker import get_solana_tracker
|
|
|
|
st = get_solana_tracker()
|
|
data = await st.get_token(mint)
|
|
if data:
|
|
return {"mint": mint, "data": data, "source": "solana_tracker"}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _solana_tracker_trending(**kw) -> dict | None:
|
|
"""Solana Tracker - trending tokens on Solana."""
|
|
try:
|
|
from app.caching_shield.solana_tracker import get_solana_tracker
|
|
|
|
st = get_solana_tracker()
|
|
data = await st.get_tokens_trending(limit=20)
|
|
if data:
|
|
return {"trending": data, "source": "solana_tracker"}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── MESSARI PROVIDER (FREEMIUM, 200 REQ/MIN) ─────────────
|
|
|
|
|
|
async def _messari_news(limit: int = 20, **kw) -> dict | None:
|
|
"""Messari News API - curated crypto news with per-asset sentiment scores."""
|
|
api_key = kw.get("api_key", "") or os.getenv("MESSARI_API_KEY", "")
|
|
if not api_key:
|
|
return None
|
|
try:
|
|
headers = {"X-Messari-API-Key": api_key, "Accept": "application/json"}
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get("https://api.messari.io/news/v1/news/feed", params={"limit": limit}, headers=headers)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
if data.get("data"):
|
|
# Transform Messari format to our standard format
|
|
articles = []
|
|
for item in data["data"]:
|
|
assets = [a.get("symbol", "Unknown") for a in item.get("assets", [])]
|
|
sentiment = item.get("sentiment", [])
|
|
avg_sentiment = sum(s.get("sentiment", 0) for s in sentiment) / max(len(sentiment), 1)
|
|
|
|
articles.append(
|
|
{
|
|
"title": item.get("title", ""),
|
|
"url": item.get("url", ""),
|
|
"source": item.get("source", {}).get("sourceName", "Messari"),
|
|
"published_at": item.get("publishTime", ""),
|
|
"description": item.get("description", ""),
|
|
"assets": assets,
|
|
"sentiment_score": avg_sentiment,
|
|
"category": item.get("category", "news"),
|
|
}
|
|
)
|
|
return {"articles": articles, "source": "messari", "count": len(articles)}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── COINDESK PROVIDER (FREEMIUM, HIGH-QUALITY NEWS) ─────────────
|
|
|
|
|
|
async def _coindesk_news(limit: int = 20, **kw) -> dict | None:
|
|
"""CoinDesk News API - institutional-grade crypto news with categorization."""
|
|
api_key = kw.get("api_key", "") or os.getenv("COINDESK_API_KEY", "")
|
|
if not api_key:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(
|
|
"https://min-api.cryptocompare.com/data/v2/news/",
|
|
params={"lang": "EN", "limit": limit, "api_key": api_key},
|
|
)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
if data.get("Response") == "Success" and data.get("Data"):
|
|
articles = []
|
|
for item in data["Data"]:
|
|
articles.append(
|
|
{
|
|
"title": item.get("title", ""),
|
|
"url": item.get("url", ""),
|
|
"source": item.get("source", "CoinDesk"),
|
|
"published_at": datetime.fromtimestamp(
|
|
item.get("published_on", 0), tz=timezone.utc # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue
|
|
).isoformat(),
|
|
"description": item.get("body", ""),
|
|
"category": item.get("categories", "news").lower(),
|
|
"upvotes": item.get("upvotes", 0),
|
|
"downvotes": item.get("downvotes", 0),
|
|
}
|
|
)
|
|
return {"articles": articles, "source": "coindesk", "count": len(articles)}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── SANTIMENT PROVIDER (FREEMIUM, DEV ACTIVITY & SOCIAL) ───────
|
|
|
|
|
|
async def _santiment_dev_activity(project_slug: str = "bitcoin", **kw) -> dict | None:
|
|
"""Santiment - GitHub dev activity and social volume (free tier: 100 calls/day)."""
|
|
api_key = kw.get("api_key", "") or os.getenv("SANTIMENT_API_KEY", "")
|
|
if not api_key or not project_slug:
|
|
return None
|
|
try:
|
|
query = f"""
|
|
{{
|
|
getMetric(metric: "dev_activity")
|
|
{{
|
|
timeseriesData(
|
|
slug: "{project_slug}"
|
|
from: "30d_ago"
|
|
to: "now"
|
|
interval: "1d"
|
|
)
|
|
}}
|
|
}}
|
|
"""
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.post(
|
|
"https://api.santiment.net/graphql",
|
|
json={"query": query},
|
|
headers={"Authorization": f"Apikey {api_key}"},
|
|
)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
return {
|
|
"project": project_slug,
|
|
"dev_activity": data.get("data", {}).get("getMetric", {}).get("timeseriesData", []),
|
|
"source": "santiment",
|
|
}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── VIRUSTOTAL PROVIDER (FREE, 500 REQ/DAY) ────────────────────
|
|
|
|
|
|
async def _virustotal_url_scan(url: str, **kw) -> dict | None:
|
|
"""VirusTotal - scan token website URLs for phishing/malware (free: 500 req/day)."""
|
|
api_key = kw.get("api_key", "") or os.getenv("VIRUSTOTAL_API_KEY", "")
|
|
if not api_key or not url:
|
|
return None
|
|
try:
|
|
import base64
|
|
|
|
url_id = base64.urlsafe_b64encode(url.encode()).decode().strip("=")
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(f"https://www.virustotal.com/api/v3/urls/{url_id}", headers={"x-apikey": api_key})
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
stats = data.get("data", {}).get("attributes", {}).get("last_analysis_stats", {})
|
|
return {
|
|
"url": url,
|
|
"malicious": stats.get("malicious", 0),
|
|
"suspicious": stats.get("suspicious", 0),
|
|
"harmless": stats.get("harmless", 0),
|
|
"source": "virustotal",
|
|
}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── DUNE ANALYTICS PROVIDER (FREE TIER: 10K CU/MO + FALLBACK) ──
|
|
|
|
|
|
async def _dune_early_buyers(token_address: str = "", chain: str = "ethereum", **kw) -> dict | None:
|
|
"""Dune Analytics - finds the first 5-minute buyers of a token.
|
|
Uses dual-key fallback to double free tier capacity (10k CU/mo per key).
|
|
Cached aggressively (4 hours) to preserve free tier.
|
|
"""
|
|
primary_key = kw.get("api_key", "") or os.getenv("DUNE_API_KEY", "")
|
|
secondary_key = os.getenv("DUNE_API_KEY_2", "")
|
|
|
|
if not token_address:
|
|
return None
|
|
|
|
query_id = 3946245 # Replace with actual saved Dune query ID for early buyers
|
|
|
|
async def _try_dune_key(api_key: str) -> dict | None:
|
|
if not api_key:
|
|
return None
|
|
try:
|
|
headers = {"X-Dune-API-Key": api_key}
|
|
params = {"token_address": token_address.lower(), "chain": chain.lower()}
|
|
|
|
async with httpx.AsyncClient(timeout=30) as c:
|
|
exec_url = f"https://api.dune.com/api/v1/query/{query_id}/execute"
|
|
r_exec = await c.post(exec_url, json={"query_parameters": params}, headers=headers)
|
|
|
|
# Check for quota/rate limit errors
|
|
if r_exec.status_code in (429, 402, 403):
|
|
return {"error": "quota_exceeded", "status": r_exec.status_code}
|
|
|
|
if r_exec.status_code == 200:
|
|
exec_id = r_exec.json().get("execution_id")
|
|
if exec_id:
|
|
for _ in range(3):
|
|
await asyncio.sleep(2)
|
|
r_result = await c.get(
|
|
f"https://api.dune.com/api/v1/execution/{exec_id}/results",
|
|
headers=headers,
|
|
)
|
|
if r_result.status_code == 200:
|
|
data = r_result.json()
|
|
if data.get("state") == "QUERY_STATE_COMPLETED":
|
|
return {
|
|
"token": token_address,
|
|
"chain": chain,
|
|
"early_buyers": data.get("result", {}).get("rows", [])[:20],
|
|
"source": "dune",
|
|
}
|
|
elif r_result.status_code != 202:
|
|
break
|
|
except Exception as e:
|
|
logger.warning(f"Dune query failed with key: {e}")
|
|
return None
|
|
|
|
# Try primary key first
|
|
result = await _try_dune_key(primary_key)
|
|
|
|
# If primary key failed due to quota/rate limit, try secondary key
|
|
if isinstance(result, dict) and result.get("error") == "quota_exceeded":
|
|
logger.info("Dune primary key quota exceeded, failing over to secondary key")
|
|
result = await _try_dune_key(secondary_key)
|
|
|
|
# If result is the error dict, return None to let DataBus handle fallback
|
|
if isinstance(result, dict) and result.get("error") == "quota_exceeded":
|
|
return None
|
|
|
|
return result
|
|
|
|
|
|
|
|
# ── HYPERLIQUID PASS-THROUGH ─────────────────────────────────────
|
|
|
|
async def _passthrough_hyperliquid(**kwargs) -> dict | None:
|
|
"""Hyperliquid perp markets from our own /api/v1/market/hyperliquid."""
|
|
try:
|
|
limit = kwargs.get("limit", 10)
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(f"http://localhost:8000/api/v1/market/hyperliquid?limit={limit}")
|
|
if r.status_code == 200:
|
|
return r.json()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _passthrough_hyperliquid_action(**kwargs) -> dict | None:
|
|
"""Hyperliquid gain/loss porn from our own /api/v1/market/hyperliquid-action."""
|
|
try:
|
|
limit = kwargs.get("limit", 5)
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(f"http://localhost:8000/api/v1/market/hyperliquid-action?limit={limit}")
|
|
if r.status_code == 200:
|
|
return r.json()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── INSIDER WALLETS PASS-THROUGH ─────────────────────────────────
|
|
|
|
async def _passthrough_insider_wallets(**kwargs) -> dict | None:
|
|
"""Likely insider trader wallets from our own /api/v1/market/insider-wallets."""
|
|
try:
|
|
limit = kwargs.get("limit", 10)
|
|
tier = kwargs.get("tier", "free")
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(f"http://localhost:8000/api/v1/market/insider-wallets?limit={limit}&tier={tier}")
|
|
if r.status_code == 200:
|
|
return r.json()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── PREDICTION SIGNALS PASS-THROUGH ─────────────────────────────
|
|
|
|
async def _passthrough_prediction_signals(**kwargs) -> dict | None:
|
|
"""Prediction market intelligence from our own /api/v1/market/prediction-signals."""
|
|
try:
|
|
limit = kwargs.get("limit", 5)
|
|
tier = kwargs.get("tier", "free")
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(f"http://localhost:8000/api/v1/market/prediction-signals?limit={limit}&tier={tier}")
|
|
if r.status_code == 200:
|
|
return r.json()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|