- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
371 lines
14 KiB
Python
371 lines
14 KiB
Python
"""
|
|
Moralis Connector - Data API + Auth API.
|
|
Key 1: Data API (wallets, tokens, NFTs, streams, whale tracking)
|
|
Key 2: Auth API (Sign-In With Ethereum/Solana - Phantom, MetaMask, etc.)
|
|
|
|
Free tier: 40,000 API credits/month (~1,333/day).
|
|
Rate-limited to 3 req/sec with caching.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import time
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── API Keys ────────────────────────────────────────────────
|
|
|
|
MORALIS_API_KEY = os.getenv("MORALIS_API_KEY", "")
|
|
MORALIS_API_KEY_2 = os.getenv("MORALIS_API_KEY_2", "") # Data+Auth (org 510042)
|
|
MORALIS_API_KEY_3 = os.getenv("MORALIS_API_KEY_3", "") # Data+Auth (org 516732)
|
|
|
|
DATA_URL = "https://deep-index.moralis.io/api/v2.2"
|
|
AUTH_URL = "https://authapi.moralis.io"
|
|
STREAM_URL = "https://api.moralis.io/stream"
|
|
|
|
SUPPORTED_EVM_CHAINS = {
|
|
"eth": "0x1",
|
|
"goerli": "0x5",
|
|
"sepolia": "0xaa36a7",
|
|
"polygon": "0x89",
|
|
"mumbai": "0x13881",
|
|
"bsc": "0x38",
|
|
"bsc_testnet": "0x61",
|
|
"avalanche": "0xa86a",
|
|
"fantom": "0xfa",
|
|
"arbitrum": "0xa4b1",
|
|
"optimism": "0xa",
|
|
"base": "0x2105",
|
|
"gnosis": "0x64",
|
|
"celo": "0xa4ec",
|
|
"palm": "0x2a15c308d",
|
|
}
|
|
|
|
CHAIN_ID_MAP = {
|
|
"eth": "1",
|
|
"goerli": "5",
|
|
"sepolia": "11155111",
|
|
"polygon": "137",
|
|
"mumbai": "80001",
|
|
"bsc": "56",
|
|
"bsc_testnet": "97",
|
|
"avalanche": "43114",
|
|
"fantom": "250",
|
|
"arbitrum": "42161",
|
|
"optimism": "10",
|
|
"base": "8453",
|
|
"gnosis": "100",
|
|
}
|
|
|
|
|
|
class MoralisConnector:
|
|
"""Moralis Data + Auth API connector."""
|
|
|
|
def __init__(self):
|
|
# Key rotation: spread requests across 2 orgs for 2x rate limit
|
|
self._data_keys = [k for k in [MORALIS_API_KEY, MORALIS_API_KEY_2, MORALIS_API_KEY_3] if k]
|
|
self._auth_keys = [k for k in [MORALIS_API_KEY_2, MORALIS_API_KEY_3] if k]
|
|
self.api_key = self._data_keys[0] if self._data_keys else ""
|
|
self.auth_key = self._auth_keys[0] if self._auth_keys else ""
|
|
self._data_idx = 0
|
|
self._auth_idx = 0
|
|
self._cache: dict[str, tuple] = {} # key → (data, timestamp)
|
|
self._cache_ttl = 300 # 5 min
|
|
self._last_request = 0.0
|
|
self._min_interval = 0.33 # 3 req/sec
|
|
|
|
def _chain_param(self, chain: str) -> str:
|
|
"""Convert chain name to Moralis chain param."""
|
|
return chain if chain in SUPPORTED_EVM_CHAINS else "eth"
|
|
|
|
def _chain_id(self, chain: str) -> str:
|
|
"""Convert chain name to chainId for auth."""
|
|
return CHAIN_ID_MAP.get(chain, "1")
|
|
|
|
async def _rate_limit(self):
|
|
now = time.monotonic()
|
|
elapsed = now - self._last_request
|
|
if elapsed < self._min_interval:
|
|
await asyncio.sleep(self._min_interval - elapsed)
|
|
self._last_request = time.monotonic()
|
|
|
|
def _cached(self, key: str) -> Any | None:
|
|
if key in self._cache:
|
|
data, ts = self._cache[key]
|
|
if time.time() - ts < self._cache_ttl:
|
|
return data
|
|
return None
|
|
|
|
def _set_cache(self, key: str, data: Any):
|
|
self._cache[key] = (data, time.time())
|
|
# Evict old entries
|
|
if len(self._cache) > 500:
|
|
oldest = min(self._cache, key=lambda k: self._cache[k][1])
|
|
del self._cache[oldest]
|
|
|
|
async def _get(self, url: str, key: str | None = None, use_auth_key: bool = False) -> dict | None:
|
|
"""Rate-limited GET with caching and key rotation."""
|
|
cache_key = f"get:{url}"
|
|
if key is None:
|
|
key = cache_key
|
|
cached = self._cached(key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
await self._rate_limit()
|
|
# Rotate keys for load balancing (2 orgs = 2x rate limit)
|
|
keys = self._auth_keys if use_auth_key else self._data_keys
|
|
if not keys:
|
|
return None
|
|
idx = self._auth_idx if use_auth_key else self._data_idx
|
|
api_key = keys[idx % len(keys)]
|
|
if use_auth_key:
|
|
self._auth_idx = idx + 1
|
|
else:
|
|
self._data_idx = idx + 1
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
r = await client.get(
|
|
url,
|
|
headers={
|
|
"X-API-Key": api_key,
|
|
"Accept": "application/json",
|
|
},
|
|
)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
self._set_cache(key, data)
|
|
return data
|
|
elif r.status_code == 429:
|
|
logger.warning("Moralis rate limited")
|
|
return None
|
|
else:
|
|
logger.debug(f"Moralis {r.status_code}: {url[:80]}")
|
|
return None
|
|
except Exception as e:
|
|
logger.debug(f"Moralis request failed: {e}")
|
|
return None
|
|
|
|
# ── Auth API (Key 2) ──────────────────────────────────
|
|
|
|
async def request_evm_challenge(self, address: str, chain: str = "eth", domain: str = "rugmunch.io") -> dict | None:
|
|
"""Request SIWE (Sign-In With Ethereum) challenge for MetaMask etc."""
|
|
chain_id = self._chain_id(chain)
|
|
url = f"{AUTH_URL}/challenge/request/evm"
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
r = await client.post(
|
|
url,
|
|
headers={
|
|
"X-API-Key": self.auth_key,
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"address": address,
|
|
"chainId": chain_id,
|
|
"domain": domain,
|
|
"uri": f"https://{domain}",
|
|
"timeout": 60,
|
|
},
|
|
)
|
|
if r.status_code in (200, 201):
|
|
return r.json()
|
|
logger.debug(f"EVM challenge failed: {r.status_code} {r.text[:100]}")
|
|
return None
|
|
except Exception as e:
|
|
logger.debug(f"EVM challenge error: {e}")
|
|
return None
|
|
|
|
async def request_solana_challenge(self, address: str, domain: str = "rugmunch.io") -> dict | None:
|
|
"""Request SIWS (Sign-In With Solana) challenge for Phantom etc."""
|
|
url = f"{AUTH_URL}/challenge/request/solana"
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
r = await client.post(
|
|
url,
|
|
headers={
|
|
"X-API-Key": self.auth_key,
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"address": address,
|
|
"network": "mainnet",
|
|
"domain": domain,
|
|
"uri": f"https://{domain}",
|
|
"timeout": 60,
|
|
},
|
|
)
|
|
if r.status_code in (200, 201):
|
|
return r.json()
|
|
logger.debug(f"Solana challenge failed: {r.status_code} {r.text[:100]}")
|
|
return None
|
|
except Exception as e:
|
|
logger.debug(f"Solana challenge error: {e}")
|
|
return None
|
|
|
|
async def verify_evm_signature(self, message: str, signature: str) -> dict | None:
|
|
"""Verify EVM wallet signature, get JWT token."""
|
|
url = f"{AUTH_URL}/challenge/verify/evm"
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
r = await client.post(
|
|
url,
|
|
headers={
|
|
"X-API-Key": self.auth_key,
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={"message": message, "signature": signature},
|
|
)
|
|
if r.status_code in (200, 201):
|
|
return r.json() # Contains JWT token
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
async def verify_solana_signature(self, message: str, signature: str) -> dict | None:
|
|
"""Verify Solana wallet signature, get JWT token."""
|
|
url = f"{AUTH_URL}/challenge/verify/solana"
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
r = await client.post(
|
|
url,
|
|
headers={
|
|
"X-API-Key": self.auth_key,
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={"message": message, "signature": signature},
|
|
)
|
|
if r.status_code in (200, 201):
|
|
return r.json() # Contains JWT token
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
# ── Data API (Key 1) ──────────────────────────────────
|
|
|
|
async def get_wallet_tokens(self, address: str, chain: str = "eth") -> list[dict]:
|
|
"""Get ERC-20 tokens for a wallet."""
|
|
url = f"{DATA_URL}/{address}/erc20?chain={self._chain_param(chain)}&limit=100"
|
|
result = await self._get(url)
|
|
if isinstance(result, list):
|
|
return result
|
|
return result.get("result", result.get("data", [])) if result else []
|
|
|
|
async def get_wallet_nfts(self, address: str, chain: str = "eth", limit: int = 20) -> list[dict]:
|
|
"""Get NFTs owned by wallet."""
|
|
url = f"{DATA_URL}/{address}/nft?chain={self._chain_param(chain)}&limit={limit}&normalizeMetadata=true"
|
|
result = await self._get(url)
|
|
if isinstance(result, list):
|
|
return result
|
|
return result.get("result", result.get("data", [])) if result else []
|
|
|
|
async def get_wallet_native_balance(self, address: str, chain: str = "eth") -> dict:
|
|
"""Get native token balance (ETH/MATIC/BNB etc)."""
|
|
url = f"{DATA_URL}/{address}/balance?chain={self._chain_param(chain)}"
|
|
return await self._get(url) or {}
|
|
|
|
async def get_wallet_token_transfers(self, address: str, chain: str = "eth", limit: int = 20) -> list[dict]:
|
|
"""Get token transfer history for whale tracking."""
|
|
url = f"{DATA_URL}/{address}/erc20/transfers?chain={self._chain_param(chain)}&limit={limit}"
|
|
result = await self._get(url)
|
|
if isinstance(result, list):
|
|
return result
|
|
return result.get("result", result.get("data", [])) if result else []
|
|
|
|
async def get_token_price(self, token_address: str, chain: str = "eth") -> dict:
|
|
"""Get token price in USD."""
|
|
url = f"{DATA_URL}/erc20/{token_address}/price?chain={self._chain_param(chain)}"
|
|
return await self._get(url) or {}
|
|
|
|
async def get_token_metadata(self, token_address: str, chain: str = "eth") -> dict:
|
|
"""Get token metadata (name, symbol, decimals)."""
|
|
url = f"{DATA_URL}/erc20/{token_address}?chain={self._chain_param(chain)}"
|
|
return await self._get(url) or {}
|
|
|
|
async def get_block(self, block_number: int, chain: str = "eth") -> dict:
|
|
"""Get block data by number."""
|
|
url = f"{DATA_URL}/block/{block_number}?chain={self._chain_param(chain)}"
|
|
return await self._get(url) or {}
|
|
|
|
async def get_token_holders(self, token_address: str, chain: str = "eth", limit: int = 20) -> list[dict]:
|
|
"""Get top token holders (EVM chains)."""
|
|
url = f"{DATA_URL}/erc20/{token_address}/owners?chain={self._chain_param(chain)}&limit={limit}"
|
|
result = await self._get(url)
|
|
if isinstance(result, list):
|
|
return result
|
|
return result.get("result", result.get("data", [])) if result else []
|
|
|
|
# ── Stream Management ──────────────────────────────────
|
|
|
|
async def list_streams(self) -> list[dict]:
|
|
"""List all Moralis streams."""
|
|
url = f"{STREAM_URL}/streams"
|
|
result = await self._get(url, use_auth_key=False)
|
|
if isinstance(result, dict) and "result" in result:
|
|
return result["result"]
|
|
return result if isinstance(result, list) else []
|
|
|
|
async def create_stream(
|
|
self,
|
|
webhook_url: str,
|
|
description: str,
|
|
chains: list[str] | None = None,
|
|
address: str | None = None,
|
|
topic0: list[str] | None = None,
|
|
) -> dict | None:
|
|
"""Create a Moralis stream (webhook for EVM events)."""
|
|
if chains is None:
|
|
chains = ["0x1"] # Ethereum mainnet
|
|
|
|
payload = {
|
|
"webhookUrl": webhook_url,
|
|
"description": description,
|
|
"chainIds": chains,
|
|
"tag": description.replace(" ", "_").lower(),
|
|
}
|
|
if address:
|
|
payload["address"] = address
|
|
if topic0:
|
|
payload["topic0"] = topic0
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
r = await client.post(
|
|
f"{STREAM_URL}/stream",
|
|
headers={"X-API-Key": self.api_key, "Content-Type": "application/json"},
|
|
json=payload,
|
|
)
|
|
if r.status_code in (200, 201):
|
|
return r.json()
|
|
logger.debug(f"Create stream failed: {r.status_code} {r.text[:200]}")
|
|
return None
|
|
except Exception as e:
|
|
logger.debug(f"Create stream error: {e}")
|
|
return None
|
|
|
|
def status(self) -> dict:
|
|
"""Return connector status."""
|
|
return {
|
|
"data_api": bool(self.api_key),
|
|
"auth_api": bool(self.auth_key),
|
|
"data_key_prefix": self.api_key[:20] + "..." if self.api_key else "NOT SET",
|
|
"auth_key_prefix": self.auth_key[:20] + "..." if self.auth_key else "NOT SET",
|
|
"supported_chains": list(SUPPORTED_EVM_CHAINS.keys()),
|
|
"cache_entries": len(self._cache),
|
|
}
|
|
|
|
|
|
# Singleton
|
|
_moralis: MoralisConnector | None = None
|
|
|
|
|
|
def get_moralis_connector() -> MoralisConnector:
|
|
global _moralis
|
|
if _moralis is None:
|
|
_moralis = MoralisConnector()
|
|
return _moralis
|