560 lines
19 KiB
Python
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",
|
|
],
|
|
}
|