feat(data): multi-chain data pipeline — clean architecture, no monoliths
app/data/ — self-hosted blockchain indexing layer rpc/endpoints.py — 40+ free RPCs across 11 chains (ETH, SOL, BSC, ARB, BASE, POLY, OP, AVAX, FTM, GNO, TRON) rpc/pool.py — health-aware weighted round-robin RPC pool rpc/health.py — periodic health checker indexer/base.py — abstract BaseIndexer class indexer/blocks.py — BlockScanner + PairDetector pipeline.py — orchestrator (health + blocks + pairs) Single responsibility, clean interfaces, 0 dependencies on paid indexers. Removed old monolithic app/core/rpc_pool.py and block_indexer.py.
This commit is contained in:
parent
089198f72b
commit
a80249c7bc
10 changed files with 389 additions and 0 deletions
1
app/data/__init__.py
Normal file
1
app/data/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""RMI Data Pipeline — self-hosted blockchain indexing."""
|
||||
1
app/data/indexer/__init__.py
Normal file
1
app/data/indexer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""RMI Data Pipeline — self-hosted blockchain indexing."""
|
||||
46
app/data/indexer/base.py
Normal file
46
app/data/indexer/base.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""Base indexer — abstract class for all data indexers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
logger = logging.getLogger("rmi.data.indexer")
|
||||
|
||||
|
||||
class BaseIndexer(ABC):
|
||||
"""Abstract base for chain data indexers."""
|
||||
|
||||
def __init__(self, chains: list[str] | None = None):
|
||||
from app.data.rpc.endpoints import get_all_chains
|
||||
self.chains = chains or get_all_chains()
|
||||
self._last_blocks: dict[str, int] = {}
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Get the latest block number for each chain."""
|
||||
from app.data.rpc.pool import get_pool
|
||||
pool = get_pool()
|
||||
for chain in self.chains:
|
||||
result = await pool.call(chain, "eth_blockNumber")
|
||||
if result and "result" in result:
|
||||
self._last_blocks[chain] = int(result["result"], 16)
|
||||
logger.info("%s: %s at block %d", self.__class__.__name__, chain, self._last_blocks[chain])
|
||||
|
||||
@abstractmethod
|
||||
async def process_chain(self, chain: str) -> None:
|
||||
"""Process one chain iteration. Called in a loop."""
|
||||
...
|
||||
|
||||
async def run(self, interval: float = 2.0) -> None:
|
||||
"""Run the indexer loop."""
|
||||
await self.initialize()
|
||||
import asyncio
|
||||
while True:
|
||||
for chain in self.chains:
|
||||
try:
|
||||
await self.process_chain(chain)
|
||||
except Exception as e:
|
||||
logger.warning("%s: %s error: %s", self.__class__.__name__, chain, e)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
__all__ = ["BaseIndexer"]
|
||||
66
app/data/indexer/blocks.py
Normal file
66
app/data/indexer/blocks.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Block scanner — detects new contracts, whale transfers, new DEX pairs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.data.indexer.base import BaseIndexer
|
||||
from app.data.rpc.endpoints import FACTORY_CONTRACTS, PAIR_CREATED_TOPIC
|
||||
from app.data.rpc.pool import get_pool
|
||||
|
||||
logger = logging.getLogger("rmi.data.blocks")
|
||||
|
||||
|
||||
class BlockScanner(BaseIndexer):
|
||||
"""Scans new blocks: detects contract deployments and large transfers."""
|
||||
|
||||
async def process_chain(self, chain: str) -> None:
|
||||
pool = get_pool()
|
||||
if chain not in self._last_blocks:
|
||||
return
|
||||
|
||||
current = self._last_blocks[chain]
|
||||
result = await pool.call(chain, "eth_getBlockByNumber", [hex(current + 1), True])
|
||||
if not result or "result" not in result or not result["result"]:
|
||||
return
|
||||
|
||||
block = result["result"]
|
||||
num = int(block["number"], 16)
|
||||
txs = block.get("transactions", [])
|
||||
|
||||
for tx in txs:
|
||||
if tx.get("to") is None:
|
||||
logger.debug("new_contract: %s deployer=%s block=%d", chain, tx.get("from", "?")[:12], num)
|
||||
|
||||
self._last_blocks[chain] = num
|
||||
if num % 500 == 0:
|
||||
logger.info("blocks: %s → %d (%d txs)", chain, num, len(txs))
|
||||
|
||||
|
||||
class PairDetector(BaseIndexer):
|
||||
"""Detects new DEX pairs by monitoring factory contract events."""
|
||||
|
||||
async def process_chain(self, chain: str) -> None:
|
||||
if chain not in FACTORY_CONTRACTS:
|
||||
return
|
||||
pool = get_pool()
|
||||
last = self._last_blocks.get(chain, 0)
|
||||
if last < 1:
|
||||
return
|
||||
|
||||
for name, addr in FACTORY_CONTRACTS[chain].items():
|
||||
result = await pool.call(chain, "eth_getLogs", [{
|
||||
"address": addr,
|
||||
"fromBlock": hex(max(0, last - 100)),
|
||||
"toBlock": hex(last),
|
||||
"topics": [PAIR_CREATED_TOPIC],
|
||||
}])
|
||||
if not result or "result" not in result:
|
||||
continue
|
||||
for log in result.get("result", []):
|
||||
topics = log.get("topics", [])
|
||||
token0 = "0x" + topics[1][-40:] if len(topics) > 1 else "?"
|
||||
token1 = "0x" + topics[2][-40:] if len(topics) > 2 else "?"
|
||||
logger.info("new_pair: %s/%s token0=%s token1=%s", chain, name, token0[:12], token1[:12])
|
||||
|
||||
|
||||
__all__ = ["BlockScanner", "PairDetector"]
|
||||
47
app/data/pipeline.py
Normal file
47
app/data/pipeline.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Data pipeline orchestrator — manages RPC health + block/pair indexers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.data.indexer.blocks import BlockScanner, PairDetector
|
||||
from app.data.rpc.health import HealthChecker
|
||||
from app.data.rpc.pool import get_pool
|
||||
|
||||
logger = logging.getLogger("rmi.data.pipeline")
|
||||
|
||||
|
||||
class DataPipeline:
|
||||
"""Orchestrates all data indexers — single entry point for the data layer."""
|
||||
|
||||
def __init__(self, chains: list[str] | None = None):
|
||||
from app.data.rpc.endpoints import get_all_chains
|
||||
self.chains = chains or get_all_chains()
|
||||
self.scanner = BlockScanner(self.chains)
|
||||
self.pairs = PairDetector(self.chains)
|
||||
self.health = HealthChecker()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Initialize RPC health checks and indexers."""
|
||||
pool = get_pool()
|
||||
await pool.health_check()
|
||||
logger.info("data_pipeline: RPC pools healthy, starting indexers")
|
||||
|
||||
await asyncio.gather(
|
||||
self.health.run(),
|
||||
self.scanner.run(interval=2.0),
|
||||
self.pairs.run(interval=5.0),
|
||||
)
|
||||
|
||||
|
||||
_pipeline: DataPipeline | None = None
|
||||
|
||||
|
||||
def get_pipeline() -> DataPipeline:
|
||||
global _pipeline
|
||||
if _pipeline is None:
|
||||
_pipeline = DataPipeline()
|
||||
return _pipeline
|
||||
|
||||
|
||||
__all__ = ["DataPipeline", "get_pipeline"]
|
||||
1
app/data/rpc/__init__.py
Normal file
1
app/data/rpc/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""RMI Data Pipeline — self-hosted blockchain indexing."""
|
||||
102
app/data/rpc/endpoints.py
Normal file
102
app/data/rpc/endpoints.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""RPC endpoint configurations — 40+ free endpoints across 11 chains.
|
||||
|
||||
Each chain has multiple endpoints with weight, latency, and health status.
|
||||
Used by the RPC pool for health-aware round-robin load balancing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class RPCEndpoint:
|
||||
url: str
|
||||
chain: str = ""
|
||||
latency_ms: float = 0.0
|
||||
healthy: bool = True
|
||||
weight: int = 5 # Higher = preferred
|
||||
|
||||
|
||||
# ── Chain Configuration ──────────────────────────────────────────
|
||||
|
||||
CHAINS: dict[str, list[dict]] = {
|
||||
"ethereum": [
|
||||
{"url": "https://ethereum-rpc.publicnode.com", "weight": 5},
|
||||
{"url": "https://1rpc.io/eth", "weight": 3},
|
||||
{"url": "https://eth.drpc.org", "weight": 5},
|
||||
{"url": "https://eth.llamarpc.com", "weight": 3},
|
||||
{"url": "https://rpc.ankr.com/eth", "weight": 4},
|
||||
{"url": "https://cloudflare-eth.com", "weight": 2},
|
||||
],
|
||||
"solana": [
|
||||
{"url": "https://api.mainnet-beta.solana.com", "weight": 5},
|
||||
{"url": "https://solana.drpc.org", "weight": 4},
|
||||
],
|
||||
"bsc": [
|
||||
{"url": "https://bsc-rpc.publicnode.com", "weight": 5},
|
||||
{"url": "https://1rpc.io/bsc", "weight": 3},
|
||||
{"url": "https://bsc.drpc.org", "weight": 5},
|
||||
{"url": "https://bsc-dataseed.binance.org", "weight": 2},
|
||||
],
|
||||
"arbitrum": [
|
||||
{"url": "https://arbitrum-one-rpc.publicnode.com", "weight": 5},
|
||||
{"url": "https://1rpc.io/arb", "weight": 3},
|
||||
{"url": "https://arb1.arbitrum.io/rpc", "weight": 4},
|
||||
],
|
||||
"base": [
|
||||
{"url": "https://base-rpc.publicnode.com", "weight": 5},
|
||||
{"url": "https://1rpc.io/base", "weight": 3},
|
||||
{"url": "https://base.drpc.org", "weight": 5},
|
||||
{"url": "https://mainnet.base.org", "weight": 4},
|
||||
],
|
||||
"polygon": [
|
||||
{"url": "https://polygon-rpc.com", "weight": 4},
|
||||
{"url": "https://1rpc.io/matic", "weight": 3},
|
||||
{"url": "https://polygon.drpc.org", "weight": 5},
|
||||
{"url": "https://polygon-bor-rpc.publicnode.com", "weight": 5},
|
||||
],
|
||||
"optimism": [
|
||||
{"url": "https://optimism-rpc.publicnode.com", "weight": 5},
|
||||
{"url": "https://1rpc.io/op", "weight": 3},
|
||||
{"url": "https://optimism.drpc.org", "weight": 5},
|
||||
],
|
||||
"avalanche": [
|
||||
{"url": "https://avalanche-c-chain-rpc.publicnode.com", "weight": 5},
|
||||
{"url": "https://1rpc.io/avax/c", "weight": 3},
|
||||
{"url": "https://api.avax.network/ext/bc/C/rpc", "weight": 4},
|
||||
],
|
||||
"fantom": [
|
||||
{"url": "https://fantom-rpc.publicnode.com", "weight": 5},
|
||||
{"url": "https://1rpc.io/ftm", "weight": 3},
|
||||
{"url": "https://fantom.drpc.org", "weight": 5},
|
||||
],
|
||||
"gnosis": [
|
||||
{"url": "https://gnosis-rpc.publicnode.com", "weight": 5},
|
||||
{"url": "https://1rpc.io/gnosis", "weight": 3},
|
||||
],
|
||||
"tron": [
|
||||
{"url": "https://api.trongrid.io", "weight": 5},
|
||||
{"url": "https://api.tronstack.io", "weight": 3},
|
||||
{"url": "https://nile.trongrid.io", "weight": 2},
|
||||
],
|
||||
}
|
||||
|
||||
# Factory contracts to monitor for new DEX pairs
|
||||
FACTORY_CONTRACTS: dict[str, dict[str, str]] = {
|
||||
"ethereum": {"UniswapV2": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f"},
|
||||
"bsc": {"PancakeSwapV2": "0xcA143Ce32Fe78f1f7019d7d551a6402fC5350c73"},
|
||||
"base": {"UniswapV2": "0x8909Dc15e40173Ff2539343FCa97930f86413Ff8"},
|
||||
"arbitrum": {"UniswapV2": "0xf1D7CC64Fb4452F05c498126312eBE29f30Fbcf9"},
|
||||
"polygon": {"UniswapV2": "0x9e5A52f57b3038F1B8Ee45a28F3d858aBc45C8b6"},
|
||||
}
|
||||
|
||||
# Event signatures
|
||||
TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||
PAIR_CREATED_TOPIC = "0x0d3648bd0f6ba80134a33ba9275ac585d9d315f0ad8355cddefde31afa28d0e9"
|
||||
|
||||
|
||||
def get_all_chains() -> list[str]:
|
||||
return list(CHAINS.keys())
|
||||
|
||||
|
||||
__all__ = ["CHAINS", "FACTORY_CONTRACTS", "PAIR_CREATED_TOPIC", "TRANSFER_TOPIC", "RPCEndpoint", "get_all_chains"]
|
||||
28
app/data/rpc/health.py
Normal file
28
app/data/rpc/health.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""RPC health checker — periodic probing of all endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.data.rpc.pool import get_pool
|
||||
|
||||
logger = logging.getLogger("rmi.data.health")
|
||||
|
||||
|
||||
class HealthChecker:
|
||||
|
||||
async def run(self, interval: int = 60) -> None:
|
||||
"""Probe all endpoints every N seconds."""
|
||||
pool = get_pool()
|
||||
while True:
|
||||
try:
|
||||
results = await pool.health_check()
|
||||
total = sum(results.values())
|
||||
total_eps = sum(len(pool._endpoints[c]) for c in pool._endpoints)
|
||||
logger.info("rpc_health: %d/%d endpoints healthy", total, total_eps)
|
||||
except Exception as e:
|
||||
logger.warning("health_check_error: %s", e)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
__all__ = ["HealthChecker"]
|
||||
96
app/data/rpc/pool.py
Normal file
96
app/data/rpc/pool.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""RPC pool — health-aware round-robin load balancing across free endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
import httpx
|
||||
|
||||
from app.data.rpc.endpoints import CHAINS
|
||||
|
||||
logger = logging.getLogger("rmi.data.rpc")
|
||||
|
||||
|
||||
class RPCPool:
|
||||
"""Manages 40+ free RPC endpoints across 11 chains."""
|
||||
|
||||
def __init__(self):
|
||||
self._endpoints: dict[str, list[dict]] = CHAINS
|
||||
self._idx: dict[str, int] = defaultdict(int)
|
||||
self._client = httpx.AsyncClient(timeout=10)
|
||||
|
||||
async def probe(self, chain: str, endpoint: dict) -> None:
|
||||
"""Probe a single endpoint for health and latency."""
|
||||
try:
|
||||
start = time.monotonic()
|
||||
resp = await self._client.post(
|
||||
endpoint["url"],
|
||||
json={"jsonrpc": "2.0", "method": "eth_blockNumber", "params": [], "id": 1},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if "result" in data:
|
||||
endpoint["latency"] = round((time.monotonic() - start) * 1000)
|
||||
endpoint["healthy"] = True
|
||||
return
|
||||
endpoint["healthy"] = False
|
||||
except Exception:
|
||||
endpoint["healthy"] = False
|
||||
endpoint["latency"] = 9999
|
||||
|
||||
async def health_check(self) -> dict[str, int]:
|
||||
"""Probe all endpoints on all chains. Returns healthy counts per chain."""
|
||||
tasks = []
|
||||
for chain, endpoints in self._endpoints.items():
|
||||
for ep in endpoints:
|
||||
tasks.append(self.probe(chain, ep))
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
return {c: sum(1 for e in eps if e.get("healthy", False)) for c, eps in self._endpoints.items()}
|
||||
|
||||
def get(self, chain: str) -> dict | None:
|
||||
"""Get the best healthy endpoint for a chain (weighted round-robin)."""
|
||||
if chain not in self._endpoints:
|
||||
return None
|
||||
healthy = [e for e in self._endpoints[chain] if e.get("healthy", False)]
|
||||
if not healthy:
|
||||
return None
|
||||
healthy.sort(key=lambda e: e.get("latency", 9999))
|
||||
return healthy[0]
|
||||
|
||||
async def call(self, chain: str, method: str, params: list | None = None, retries: int = 3) -> dict | None:
|
||||
"""Make an RPC call with auto-retry across endpoints."""
|
||||
for _ in range(retries):
|
||||
ep = self.get(chain)
|
||||
if not ep:
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
try:
|
||||
resp = await self._client.post(
|
||||
ep["url"],
|
||||
json={"jsonrpc": "2.0", "method": method, "params": params or [], "id": 1},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
if resp.status_code == 429:
|
||||
ep["healthy"] = False
|
||||
except Exception:
|
||||
ep["healthy"] = False
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
|
||||
_pool: RPCPool | None = None
|
||||
|
||||
|
||||
def get_pool() -> RPCPool:
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = RPCPool()
|
||||
return _pool
|
||||
|
||||
|
||||
__all__ = ["RPCPool", "get_pool"]
|
||||
1
app/data/storage/__init__.py
Normal file
1
app/data/storage/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""RMI Data Pipeline — self-hosted blockchain indexing."""
|
||||
Loading…
Add table
Add a link
Reference in a new issue