""" 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.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", ], }