rmi-backend/app/consensus_rpc.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- 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>
2026-07-06 15:43:20 +02:00

627 lines
24 KiB
Python

"""
Consensus RPC Client - Multi-Provider Voting with Health Tracking.
Queries 5+ Solana RPC endpoints in parallel, compares results via
N-of-M consensus voting, and returns a ConsensusResult with confidence
score, agreed/disagreed/failed sources.
EVM support via chain_id mapping for cross-chain queries.
Pattern: Match queries from N providers → vote on result → return
consensus with confidence %. 5 providers, majority=3 for high confidence.
Depends on: httpx (async HTTP), HELIUS_API_KEY env var.
"""
import asyncio
import logging
import os
import time
from dataclasses import dataclass, field
from typing import Any
import httpx
logger = logging.getLogger(__name__)
# ── Chain → RPC URL Mapping ────────────────────────────────────────────────
# Solana RPC endpoints (free + keyed, no cost for public tiers)
SOLANA_RPC_ENDPOINTS = {
"helius": "https://mainnet.helius-rpc.com/?api-key={key}",
"quicknode": "https://quiknode.pro/{key}/solana-mainnet/",
"alchemy": "https://solana-mainnet.g.alchemy.com/v2/{key}",
"drpc": "https://solana.drpc.org",
"publicnode": "https://solana-rpc.publicnode.com",
"anvil": "https://api.mainnet-beta.solana.com",
}
# EVM chain_id → default PublicNode RPC
CHAIN_ID_TO_DEFAULT_RPC = {
1: "https://ethereum-rpc.publicnode.com",
56: "https://bsc-rpc.publicnode.com",
137: "https://polygon-rpc.publicnode.com",
8453: "https://base-rpc.publicnode.com",
42161: "https://arbitrum-rpc.publicnode.com",
10: "https://optimism-rpc.publicnode.com",
43114: "https://avalanche-c-chain-rpc.publicnode.com",
250: "https://fantom-rpc.publicnode.com",
}
# EVM multi-provider map: chain_id → [(name, url_template, requires_key)]
# url_template uses {key} if API-keyed
EVM_RPC_PROVIDERS: dict[int, list[tuple[str, str, bool]]] = {
1: [
("ethereum_publicnode", "https://ethereum-rpc.publicnode.com", False),
("llama_rpc", "https://eth.llamarpc.com", False),
("1rpc", "https://1rpc.io/eth", False),
("blastapi", "https://eth-mainnet.public.blastapi.io", False),
],
56: [
("bsc_publicnode", "https://bsc-rpc.publicnode.com", False),
("1rpc", "https://1rpc.io/bnb", False),
("blastapi", "https://bsc-mainnet.public.blastapi.io", False),
],
137: [
("polygon_publicnode", "https://polygon-rpc.publicnode.com", False),
("1rpc", "https://1rpc.io/matic", False),
],
8453: [
("base_publicnode", "https://base-rpc.publicnode.com", False),
("1rpc", "https://1rpc.io/base", False),
],
42161: [
("arbitrum_publicnode", "https://arbitrum-rpc.publicnode.com", False),
("1rpc", "https://1rpc.io/arb", False),
],
}
# ── Data Classes ───────────────────────────────────────────────────────────
@dataclass
class RpcEndpoint:
"""A single RPC provider with health tracking."""
name: str
url: str
weight: float = 1.0 # Initial weight; decays on failure
requires_key: bool = False
# Health tracking
total_calls: int = 0
success_calls: int = 0
failure_calls: int = 0
last_latency: float = 0.0
last_error: str = ""
is_disabled: bool = False
@property
def success_rate(self) -> float:
if self.total_calls == 0:
return 1.0
return self.success_calls / self.total_calls
@property
def effective_weight(self) -> float:
"""Weight adjusted by health. Disabled endpoints get weight 0."""
if self.is_disabled:
return 0.0
# Blend base weight with recent success rate (70/30)
return self.weight * (0.7 + 0.3 * self.success_rate)
@dataclass
class ConsensusResult:
"""Result of a multi-provider consensus query."""
value: Any = None
confidence: float = 0.0 # 0-100%
agreed_sources: list[str] = field(default_factory=list)
disagreed_sources: list[str] = field(default_factory=list)
failed_sources: list[str] = field(default_factory=list)
total_sources: int = 0
response_count: int = 0
details: dict[str, Any] = field(default_factory=dict)
@property
def is_reliable(self) -> bool:
return self.confidence >= 60.0
# ── Consensus RPC Client ───────────────────────────────────────────────────
class ConsensusRpcClient:
"""Multi-provider RPC client with N-of-M consensus voting.
Queries 5+ Solana RPC endpoints in parallel. Compares responses
(JSON-string equality for complex types, direct equality for scalars).
Returns the majority-agreed value with confidence score.
For EVM chains, uses the chain_id → provider mapping with
eth_call / eth_getBalance / etc.
"""
# Individual timeout per endpoint query
PER_ENDPOINT_TIMEOUT = 8.0
# Minimum number of responders before we compute consensus
MIN_RESPONDERS = 2
# If an endpoint fails 3 times in a row, disable it for 60s
MAX_CONSECUTIVE_FAILURES = 3
DISABLE_DURATION = 60.0
def __init__(self):
self._endpoints: dict[str, RpcEndpoint] = {}
self._lock = asyncio.Lock()
self._setup_solana_endpoints()
self._disabled_until: dict[str, float] = {}
def _helius_keys(self) -> list[str]:
"""Return all configured Helius API keys (up to 3)."""
keys = []
for suffix in ("", "_2", "_3"):
k = os.getenv(f"HELIUS_API_KEY{suffix}", "")
if k and k != "your_helius_key_here" and len(k) > 10:
keys.append(k)
return keys
def _setup_solana_endpoints(self):
"""Build Solana RPC endpoint roster with ALL keys where available.
Each Helius key becomes its own endpoint (helius, helius_2, helius_3)
so consensus voting can use them as independent providers with their
own rate limits.
"""
helius_keys = self._helius_keys()
quicknode_key = os.getenv("QUICKNODE_KEY", "")
alchemy_key = os.getenv("ALCHEMY_SOLANA_KEY", "")
endpoints = []
# Helius: one endpoint per key (each has its own 25 RPS free tier)
for i, key in enumerate(helius_keys):
suffix = f"_{i + 1}" if i > 0 else ""
endpoints.append(
(
f"helius{suffix}",
SOLANA_RPC_ENDPOINTS["helius"].format(key=key),
1.2,
True,
)
)
# QuickNode
if quicknode_key and quicknode_key != "your_quicknode_key_here":
endpoints.append(
(
"quicknode",
SOLANA_RPC_ENDPOINTS["quicknode"].format(key=quicknode_key),
1.1,
True,
)
)
# Alchemy
if alchemy_key and len(alchemy_key) > 5:
endpoints.append(
(
"alchemy",
SOLANA_RPC_ENDPOINTS["alchemy"].format(key=alchemy_key),
1.0,
True,
)
)
# Public/free endpoints - always available
endpoints += [
("drpc", SOLANA_RPC_ENDPOINTS["drpc"], 0.9, False),
("publicnode", SOLANA_RPC_ENDPOINTS["publicnode"], 0.8, False),
("anvil", SOLANA_RPC_ENDPOINTS["anvil"], 0.7, False),
]
for name, url, weight, requires_key in endpoints:
# Skip keyed providers if we don't have a valid key
if requires_key and ("missing" in url or not url.split("/")[-1].strip()):
logger.debug(f"Skipping {name}: no API key configured")
continue
self._endpoints[name] = RpcEndpoint(
name=name,
url=url,
weight=weight,
requires_key=requires_key,
)
active = [n for n, e in self._endpoints.items() if not e.is_disabled]
logger.info(f"ConsensusRPC: {len(active)} Solana endpoints active: {active}")
# ── Health Management ────────────────────────────────────────────────
async def _record_success(self, name: str, latency: float):
async with self._lock:
ep = self._endpoints.get(name)
if ep:
ep.total_calls += 1
ep.success_calls += 1
ep.last_latency = latency
ep.last_error = ""
async def _record_failure(self, name: str, error: str):
async with self._lock:
ep = self._endpoints.get(name)
if ep:
ep.total_calls += 1
ep.failure_calls += 1
ep.last_error = error
# Check for consecutive failures to disable
if ep.failure_calls >= self.MAX_CONSECUTIVE_FAILURES:
# Only disable if success rate recently is 0
if ep.success_calls == 0 or (ep.total_calls >= 5 and ep.success_rate < 0.2):
ep.is_disabled = True
self._disabled_until[name] = time.monotonic() + self.DISABLE_DURATION
logger.warning(
f"RPC {name} disabled for {self.DISABLE_DURATION}s "
f"(failures={ep.failure_calls}, rate={ep.success_rate:.0%})"
)
async def _maybe_reenable(self, name: str):
"""Re-enable an endpoint after its disable duration expires."""
until = self._disabled_until.get(name, 0)
if until and time.monotonic() > until:
async with self._lock:
ep = self._endpoints.get(name)
if ep and ep.is_disabled:
ep.is_disabled = False
ep.failure_calls = max(0, ep.failure_calls - 2) # soft reset
del self._disabled_until[name]
logger.info(f"RPC {name} re-enabled after cooldown")
# ── Core Query Logic ──────────────────────────────────────────────────
async def _query_one(
self,
endpoint: RpcEndpoint,
method: str,
params: list[Any],
) -> tuple[str, dict | None, float, str | None]:
"""Query a single endpoint. Returns (name, result_dict, latency, error_string)."""
await self._maybe_reenable(endpoint.name)
if endpoint.is_disabled:
return (endpoint.name, None, 0.0, "endpoint_disabled")
start = time.monotonic()
try:
async with httpx.AsyncClient(timeout=self.PER_ENDPOINT_TIMEOUT) as client:
r = await client.post(
endpoint.url,
json={
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
},
headers={"Content-Type": "application/json"},
)
latency = time.monotonic() - start
if r.status_code == 200:
data = r.json()
await self._record_success(endpoint.name, latency)
return (endpoint.name, data, latency, None)
elif r.status_code == 429:
await self._record_failure(endpoint.name, "rate_limited")
return (endpoint.name, None, latency, "rate_limited")
else:
await self._record_failure(endpoint.name, f"HTTP_{r.status_code}")
return (endpoint.name, None, latency, f"HTTP_{r.status_code}")
except httpx.TimeoutException:
latency = time.monotonic() - start
await self._record_failure(endpoint.name, "timeout")
return (endpoint.name, None, latency, "timeout")
except Exception as e:
latency = time.monotonic() - start
await self._record_failure(endpoint.name, str(e)[:200])
return (endpoint.name, None, latency, str(e)[:200])
async def query_with_consensus(
self,
method: str,
params: list[Any],
min_agreement: int = 2,
) -> ConsensusResult:
"""Query all endpoints in parallel and return consensus result.
Args:
method: JSON-RPC method name (e.g., 'getAccountInfo', 'getBalance')
params: JSON-RPC params list
min_agreement: Minimum number of agreeing sources for consensus
Returns:
ConsensusResult with voted value, confidence %, and source breakdown.
"""
active = [ep for ep in self._endpoints.values() if not ep.is_disabled]
if not active:
logger.error("No active RPC endpoints available")
return ConsensusResult(
value=None,
confidence=0.0,
failed_sources=[ep.name for ep in self._endpoints.values()],
total_sources=len(self._endpoints),
)
# Fire all queries in parallel with individual timeouts
tasks = [self._query_one(ep, method, params) for ep in active]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Process results
responses: dict[str, str] = {} # name → normalized_value
failures: dict[str, str] = {} # name → error
raw_results: dict[str, Any] = {} # name → raw_data (for details)
for r in results:
if isinstance(r, Exception):
logger.warning(f"Consensus query exception: {r}")
continue
name, data, _latency, error = r
if error:
failures[name] = error
elif data is not None:
# Normalize: extract 'result' field, use JSON repr for voting
result_value = data.get("result")
normalized = self._normalize(result_value)
responses[name] = normalized
raw_results[name] = data
# Count votes per normalized value
vote_counts: dict[str, list[str]] = {}
for name, norm in responses.items():
vote_counts.setdefault(norm, []).append(name)
# Find the winning value (most votes)
best_sources: list[str] = []
for norm, sources in vote_counts.items(): # noqa: B007
if len(sources) > len(best_sources):
best_sources = sources
# Determine agreed / disagreed / failed
agreed = best_sources
disagreed = [n for n in responses if n not in best_sources]
failed = list(failures.keys())
# Also count disabled as failed
failed += [
ep.name
for ep in self._endpoints.values()
if ep.is_disabled and ep.name not in agreed and ep.name not in disagreed
]
total = len(self._endpoints)
responded = len(responses)
# Calculate confidence
if responded < self.MIN_RESPONDERS:
confidence = 0.0
elif responded == 1:
confidence = 30.0 # Single source is weak
elif len(agreed) >= min_agreement:
# Confidence = (agreed / responded) * 100, with bonus for supermajority
agreement_ratio = len(agreed) / responded
supermajority_bonus = min(0.2, (len(agreed) - min_agreement) / max(total, 1) * 0.2)
confidence = min(100.0, (agreement_ratio * 80.0) + (supermajority_bonus * 100.0))
else:
# Weak consensus - disagreement
confidence = max(10.0, (len(agreed) / responded) * 50.0)
# Recover the actual value from the first agreeing source
value = None
if agreed:
first_agreeing = agreed[0]
if first_agreeing in raw_results:
value = raw_results[first_agreeing].get("result")
return ConsensusResult(
value=value,
confidence=round(confidence, 1),
agreed_sources=agreed,
disagreed_sources=disagreed,
failed_sources=failed,
total_sources=total,
response_count=responded,
details={
"method": method,
"params": params,
"raw_results": {k: v.get("result") for k, v in raw_results.items()},
"vote_counts": dict(vote_counts.items()),
},
)
def _normalize(self, value: Any) -> str:
"""Normalize a value for comparison in voting."""
if value is None:
return "__NONE__"
if isinstance(value, (bool, int, float, str)):
return str(value)
# For lists and dicts, use JSON representation
import json
try:
return json.dumps(value, sort_keys=True, default=str)
except (TypeError, ValueError):
return str(value)
# ── Convenience Methods ───────────────────────────────────────────────
async def get_account_info(self, address: str) -> ConsensusResult:
"""Get Solana account info with consensus across providers."""
return await self.query_with_consensus(
"getAccountInfo",
[address, {"encoding": "jsonParsed"}],
)
async def get_balance(self, address: str) -> ConsensusResult:
"""Get SOL balance with consensus."""
return await self.query_with_consensus("getBalance", [address])
async def get_token_supply(self, mint: str) -> ConsensusResult:
"""Get SPL token supply with consensus."""
return await self.query_with_consensus("getTokenSupply", [mint])
async def get_signatures_for_address(
self,
address: str,
limit: int = 20,
) -> ConsensusResult:
"""Get transaction signatures with consensus."""
return await self.query_with_consensus(
"getSignaturesForAddress",
[address, {"limit": limit}],
)
async def get_token_account_balance(self, token_account: str) -> ConsensusResult:
"""Get SPL token account balance with consensus."""
return await self.query_with_consensus(
"getTokenAccountBalance",
[token_account],
)
# ── EVM Chain Support ─────────────────────────────────────────────────
async def evm_query_with_consensus(
self,
chain_id: int,
method: str,
params: list[Any],
) -> ConsensusResult:
"""Query EVM chain RPC endpoints with consensus.
For EVM, we use PublicNode + other free providers (1rpc, llama, blastapi).
Map chain_id to provider list; fall back to PublicNode for unknown chains.
Args:
chain_id: Ethereum chain ID (1, 56, 8453, etc.)
method: eth_* RPC method
params: JSON-RPC params
"""
providers = EVM_RPC_PROVIDERS.get(chain_id, [])
if not providers:
# Fallback: use the default PublicNode URL for this chain
default_url = CHAIN_ID_TO_DEFAULT_RPC.get(chain_id)
if default_url:
providers = [(f"chain_{chain_id}_publicnode", default_url, False)]
else:
return ConsensusResult(
value=None,
confidence=0.0,
failed_sources=[f"unknown_chain_{chain_id}"],
total_sources=0,
)
# Build temporary endpoints for this query
evm_endpoints = []
for name, url, requires_key in providers:
evm_endpoints.append(
RpcEndpoint(
name=f"evm_{name}",
url=url,
weight=1.0,
requires_key=requires_key,
)
)
# Run queries in parallel
tasks = [self._query_one(ep, method, params) for ep in evm_endpoints]
results = await asyncio.gather(*tasks, return_exceptions=True)
responses: dict[str, str] = {}
failures: dict[str, str] = {}
raw_results: dict[str, Any] = {}
for r in results:
if isinstance(r, Exception):
continue
name, data, _latency, error = r
if error:
failures[name] = error
elif data is not None:
result_value = data.get("result")
responses[name] = self._normalize(result_value)
raw_results[name] = data
# Voting
vote_counts: dict[str, list[str]] = {}
for name, norm in responses.items():
vote_counts.setdefault(norm, []).append(name)
best_sources: list[str] = []
for norm, sources in vote_counts.items(): # noqa: B007
if len(sources) > len(best_sources):
best_sources = sources[:]
agreed = best_sources
disagreed = [n for n in responses if n not in best_sources]
total = len(evm_endpoints)
responded = len(responses)
if responded >= 2 and len(agreed) >= 2:
confidence = min(100.0, (len(agreed) / responded) * 100.0)
elif responded == 1:
confidence = 40.0
else:
confidence = 0.0
value = None
if agreed and agreed[0] in raw_results:
value = raw_results[agreed[0]].get("result")
return ConsensusResult(
value=value,
confidence=round(confidence, 1),
agreed_sources=agreed,
disagreed_sources=disagreed,
failed_sources=list(failures.keys()),
total_sources=total,
response_count=responded,
)
# ── Health & Stats ────────────────────────────────────────────────────
async def health_summary(self) -> dict[str, Any]:
"""Return health report for all configured endpoints."""
summary = {}
async with self._lock:
for name, ep in self._endpoints.items():
summary[name] = {
"url": ep.url[:80],
"weight": ep.effective_weight,
"success_rate": round(ep.success_rate * 100, 1),
"total_calls": ep.total_calls,
"success_calls": ep.success_calls,
"failure_calls": ep.failure_calls,
"last_latency": round(ep.last_latency, 3),
"last_error": ep.last_error[:100],
"disabled": ep.is_disabled,
}
return summary
async def stats(self) -> dict[str, Any]:
"""Aggregate statistics."""
return {
"total_endpoints": len(self._endpoints),
"active": sum(1 for ep in self._endpoints.values() if not ep.is_disabled),
"disabled": sum(1 for ep in self._endpoints.values() if ep.is_disabled),
"health": await self.health_summary(),
}
# ── Singleton ──────────────────────────────────────────────────────────────
_consensus_client: ConsensusRpcClient | None = None
def get_consensus_rpc() -> ConsensusRpcClient:
"""Get the global ConsensusRpcClient singleton."""
global _consensus_client
if _consensus_client is None:
_consensus_client = ConsensusRpcClient()
return _consensus_client