rmi-backend/app/domains/databus/bitquery_provider.py

560 lines
19 KiB
Python

"""
Bitquery DataBus Provider - Blockchain Intelligence via GraphQL
=================================================================
Bitquery provides deep blockchain data across 40+ chains via GraphQL.
This provider integrates with DataBus for intelligent caching and
rate-limited access.
Status: API key valid, account needs active billing period.
When billing is activated (free tier or paid), all queries work.
Data types supported:
- token_price: DEX trade prices across chains
- holder_data: Token holder distribution and whale tracking
- transaction_trace: Full transaction traces and fund flows
- dex_volume: DEX trading volume and liquidity
- smart_contract: Contract creation, calls, and events
- address_balance: Address balances across chains
- cross_chain_bridge: Bridge transaction tracking
Cache strategy:
- Historical data: 24h TTL (immutable)
- Price/volume: 5min TTL (volatile)
- Holder data: 1h TTL (semi-volatile)
- Transaction traces: 24h TTL (immutable)
Rate limits:
- Free tier: 100k credits/month, ~1 query/sec
- Developer: $99/mo, higher rate limits
- Business: $499/mo, highest rate limits
"""
import hashlib
import logging
import time
import httpx
from app.domains.databus.cache import CacheLayer, get_cache
logger = logging.getLogger("databus.bitquery")
# ── Configuration ──────────────────────────────────────────────
BITQUERY_STREAMING_URL = "https://streaming.bitquery.io/graphql"
BITQUERY_IDE_URL = "https://ide.bitquery.io/graphql"
BITQUERY_API_URL = "https://graphql.bitquery.io/"
# Cache TTLs
CACHE_TTL_PRICE = 300 # 5 min - prices are volatile
CACHE_TTL_VOLUME = 300 # 5 min
CACHE_TTL_HOLDERS = 3600 # 1 hour
CACHE_TTL_TRACE = 86400 # 24 hours - historical
CACHE_TTL_BALANCE = 600 # 10 min
# Rate limits (free tier)
FREE_CREDITS_PER_MONTH = 100_000
MAX_RPS = 1.0
class BitqueryProvider:
"""
Bitquery GraphQL API provider with intelligent caching.
Handles:
- GraphQL query construction for common blockchain data types
- Rate limiting (credit-based)
- Response caching with DataBus
- Error handling for billing/auth issues
"""
def __init__(self, cache: CacheLayer = None):
self.cache = cache or get_cache()
self._client: httpx.AsyncClient | None = None
self._api_key: str | None = None
self._oauth_token: str | None = None
self._credits_used = 0
self._monthly_reset = time.time()
self._loaded = False
async def _load_creds(self):
"""Load Bitquery credentials - env vars first, vault as fallback."""
if self._loaded:
return
import os
self._api_key = os.getenv("BITQUERY_API_KEY", "")
self._oauth_token = os.getenv("BITQUERY_OAUTH_TOKEN", "")
if self._api_key:
self._loaded = True
logger.info(
f"Bitquery creds from env (key={'' if self._api_key else ''}, oauth={'' if self._oauth_token else ''})"
)
return
# Fallback: vault
try:
import subprocess
result = subprocess.run(
["python3", "/root/.secrets/vault.py", "get", "backend/bitquery_api_key"],
capture_output=True,
text=True,
timeout=10,
)
key = result.stdout.strip()
if key and len(key) > 10:
self._api_key = key
self._loaded = True
logger.info(f"Bitquery creds from vault (key={'' if self._api_key else ''})")
except Exception as e:
logger.error(f"Failed to load Bitquery credentials: {e}")
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
base_url=BITQUERY_STREAMING_URL,
timeout=30.0,
headers={"Content-Type": "application/json"},
)
return self._client
def _check_billing(self) -> bool:
"""Check if credits are available. Returns False if billing period inactive."""
now = time.time()
if now - self._monthly_reset > 30 * 86400: # ~30 days
self._credits_used = 0
self._monthly_reset = now
return self._credits_used < FREE_CREDITS_PER_MONTH
async def _query(self, graphql_query: str, variables: dict | None = None) -> dict | None:
"""
Execute a Bitquery GraphQL query with caching and rate limiting.
Returns None on billing error (402) or auth error.
"""
await self._load_creds()
if not self._check_billing():
logger.warning("Bitquery monthly credit limit reached")
return {"error": "Monthly credit limit reached", "code": 429}
# Generate cache key from query hash
query_hash = hashlib.sha256(graphql_query.encode()).hexdigest()[:16]
cached = await self._get_cached(query_hash, graphql_query)
if cached:
return cached
client = await self._get_client()
payload = {"query": graphql_query}
if variables:
payload["variables"] = variables
headers = {"X-API-KEY": self._api_key}
if self._oauth_token:
headers["Authorization"] = f"Bearer {self._oauth_token}"
try:
resp = await client.post("", json=payload, headers=headers)
self._credits_used += 1
if resp.status_code == 402:
logger.error("Bitquery: No active billing period. Activate at bitquery.io")
return {
"error": "No active billing period",
"code": 402,
"fix": "Log into bitquery.io and activate a plan (even free tier)",
}
if resp.status_code == 401:
logger.error("Bitquery: Invalid API key or OAuth token")
return {"error": "Authentication failed", "code": 401}
resp.raise_for_status()
data = resp.json()
if "errors" in data:
logger.warning(f"Bitquery GraphQL errors: {data['errors']}")
return {"error": "GraphQL error", "details": data["errors"]}
# Cache successful response
await self._cache_response(query_hash, graphql_query, data)
return data.get("data", {})
except httpx.HTTPStatusError as e:
logger.error(f"Bitquery HTTP error: {e.response.status_code}")
return {"error": f"HTTP {e.response.status_code}", "code": e.response.status_code}
except Exception as e:
logger.error(f"Bitquery query failed: {e}")
return {"error": str(e)}
async def _get_cached(self, query_hash: str, query_text: str) -> dict | None:
"""Get cached response if available and not stale."""
cache_key = f"bitquery:{query_hash}"
# Determine TTL based on query type
ttl = CACHE_TTL_TRACE # default
if any(k in query_text.lower() for k in ["price", "volume", "trade"]):
ttl = CACHE_TTL_PRICE
elif "holder" in query_text.lower() or "balance" in query_text.lower():
ttl = CACHE_TTL_HOLDERS
cached = await self.cache.get(cache_key)
if cached:
# Handle tuple (value, is_stale) from SWR cache
if isinstance(cached, tuple):
cached = cached[0]
if cached is not None:
# Check if cache is still fresh
cached_time = cached.get("_cached_at", 0)
if time.time() - cached_time < ttl:
return cached.get("data")
return None
async def _cache_response(self, query_hash: str, query_text: str, data: dict):
"""Cache successful response with metadata."""
cache_key = f"bitquery:{query_hash}"
ttl = CACHE_TTL_TRACE
if any(k in query_text.lower() for k in ["price", "volume", "trade"]):
ttl = CACHE_TTL_PRICE
elif "holder" in query_text.lower() or "balance" in query_text.lower():
ttl = CACHE_TTL_HOLDERS
cache_data = {
"data": data,
"_cached_at": time.time(),
"_query_hash": query_hash,
}
await self.cache.set(cache_key, cache_data, ttl=ttl)
# ── Public Data Methods ──────────────────────────────────────
async def get_token_price(self, network: str, token_address: str) -> dict | None:
"""
Get latest DEX price for a token.
Args:
network: ethereum, bsc, solana, etc.
token_address: Contract address or mint
"""
query = f"""
query {{
{network}(network: {network}) {{
dexTrades(
options: {{limit: 1, desc: "block.timestamp.time"}}
baseCurrency: {{is: "{token_address}"}}
) {{
transaction {{
hash
}}
tradeAmount(in: USD)
price
baseCurrency {{
symbol
name
}}
quoteCurrency {{
symbol
}}
block {{
timestamp {{
time(format: "%Y-%m-%d %H:%M:%S")
}}
}}
}}
}}
}}
"""
return await self._query(query)
async def get_holder_distribution(self, network: str, token_address: str, limit: int = 100) -> dict | None:
"""
Get top token holders and their balances.
Args:
network: blockchain network
token_address: Token contract/mint
limit: Number of holders to return
"""
query = f"""
query {{
{network}(network: {network}) {{
address(
address: {{is: "{token_address}"}}
) {{
balances {{
currency {{
symbol
tokenType
}}
value
history {{
block
timestamp
value
}}
}}
}}
}}
}}
"""
return await self._query(query)
async def get_transaction_trace(self, network: str, tx_hash: str) -> dict | None:
"""
Get full transaction trace including internal calls.
Args:
network: blockchain network
tx_hash: Transaction hash
"""
query = f"""
query {{
{network}(network: {network}) {{
transactions(
txHash: {{is: "{tx_hash}"}}
) {{
hash
block {{
height
timestamp {{
time(format: "%Y-%m-%d %H:%M:%S")
}}
}}
sender {{
address
}}
receiver {{
address
}}
amount
gasValue
success
internalTransactions {{
sender {{
address
}}
receiver {{
address
}}
amount
gasValue
success
}}
}}
}}
}}
"""
return await self._query(query)
async def get_dex_volume(
self, network: str, pool_address: str | None = None, timeframe: str = "24h"
) -> dict | None:
"""
Get DEX trading volume for a pool or network.
Args:
network: blockchain network
pool_address: Optional specific pool
timeframe: 1h, 6h, 24h, 7d
"""
pool_filter = f'poolAddress: {{is: "{pool_address}"}}' if pool_address else ""
query = f"""
query {{
{network}(network: {network}) {{
dexTrades(
{pool_filter}
options: {{desc: "block.timestamp.time", limit: 100}}
) {{
count
tradeAmount(in: USD)
volume: tradeAmount(in: USD, calculate: sum)
block {{
timestamp {{
time(format: "%Y-%m-%d %H:%M:%S")
}}
}}
}}
}}
}}
"""
return await self._query(query)
async def get_address_balance(self, network: str, address: str) -> dict | None:
"""
Get all token balances for an address.
Args:
network: blockchain network
address: Wallet address
"""
query = f"""
query {{
{network}(network: {network}) {{
address(
address: {{is: "{address}"}}
) {{
balances {{
currency {{
symbol
name
tokenType
address
}}
value
}}
}}
}}
}}
"""
return await self._query(query)
async def get_cross_chain_transfers(self, address: str, networks: list[str] | None = None) -> dict | None:
"""
Track token transfers across multiple chains for an address.
Args:
address: Wallet address
networks: List of networks to check (default: ethereum, bsc, solana)
"""
networks = networks or ["ethereum", "bsc", "solana"]
# Build multi-network query
queries = []
for net in networks:
queries.append(f"""
{net}: {net}(network: {net}) {{
transfers(
sender: {{is: "{address}"}}
options: {{limit: 10, desc: "block.timestamp.time"}}
) {{
transaction {{
hash
}}
amount
currency {{
symbol
name
}}
receiver {{
address
}}
block {{
timestamp {{
time(format: "%Y-%m-%d %H:%M:%S")
}}
}}
}}
}}
""")
query = f"query {{ {' '.join(queries)} }}"
return await self._query(query)
async def get_smart_contract_events(
self, network: str, contract: str, event_signature: str | None = None, limit: int = 50
) -> dict | None:
"""
Get smart contract events/logs.
Args:
network: blockchain network
contract: Contract address
event_signature: Optional event signature hash
limit: Number of events
"""
event_filter = f'smartContractEvent: {{is: "{event_signature}"}}' if event_signature else ""
query = f"""
query {{
{network}(network: {network}) {{
smartContractEvents(
smartContractAddress: {{is: "{contract}"}}
{event_filter}
options: {{limit: {limit}, desc: "block.timestamp.time"}}
) {{
arguments {{
name
value
type
}}
smartContract {{
contractType
currency {{
symbol
name
}}
}}
block {{
timestamp {{
time(format: "%Y-%m-%d %H:%M:%S")
}}
}}
}}
}}
}}
"""
return await self._query(query)
# ── Health & Status ─────────────────────────────────────────
async def health(self) -> dict:
"""Provider health check."""
await self._load_creds()
# Try a minimal query to check status
test_query = """
query {
ethereum(network: ethereum) {
blocks(options: {limit: 1}) {
height
}
}
}
"""
result = await self._query(test_query)
if result and "error" in result:
error_code = result.get("code", 0)
if error_code == 402:
return {
"status": "billing_required",
"api_key_valid": True,
"billing_active": False,
"credits_used": self._credits_used,
"credits_remaining": FREE_CREDITS_PER_MONTH - self._credits_used,
"fix": "Activate billing at bitquery.io (free tier available)",
"message": "Account needs active billing period",
}
elif error_code == 401:
return {
"status": "auth_error",
"api_key_valid": False,
"message": "Invalid API key or OAuth token",
}
else:
return {"status": "error", "error": result.get("error"), "code": error_code}
return {
"status": "healthy",
"api_key_valid": True,
"billing_active": True,
"credits_used": self._credits_used,
"credits_remaining": FREE_CREDITS_PER_MONTH - self._credits_used,
"networks_supported": 40,
"data_types": [
"token_price",
"holder_data",
"transaction_trace",
"dex_volume",
"smart_contract_events",
"address_balance",
"cross_chain_transfers",
],
}